From ba88480487b721c5b4042dc3596dc0823adae6f4 Mon Sep 17 00:00:00 2001 From: StefanBratanov Date: Thu, 10 Sep 2026 19:48:48 +0300 Subject: [PATCH 1/4] Validate a bid coming from the Builder API (Gloas) --- .../OperationSignatureVerifier.java | 12 +- .../gloas/block/BlockProcessorGloas.java | 3 +- .../execution/BuilderBidFetcher.java | 29 +- .../execution/BuilderBidValidator.java | 126 +++++++ .../ExecutionPayloadBidSelector.java | 14 +- .../ExecutionPayloadBidGossipValidator.java | 2 +- .../execution/BuilderBidFetcherTest.java | 35 +- .../execution/BuilderBidValidatorTest.java | 338 ++++++++++++++++++ .../beaconchain/BeaconChainController.java | 5 +- 9 files changed, 543 insertions(+), 21 deletions(-) create mode 100644 ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidator.java create mode 100644 ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidatorTest.java diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/operations/OperationSignatureVerifier.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/operations/OperationSignatureVerifier.java index 64520233020..d8ec4b57501 100644 --- a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/operations/OperationSignatureVerifier.java +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/operations/OperationSignatureVerifier.java @@ -33,8 +33,6 @@ import tech.pegasys.teku.spec.datastructures.operations.VoluntaryExit; import tech.pegasys.teku.spec.datastructures.state.Fork; 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.gloas.Builder; import tech.pegasys.teku.spec.logic.common.helpers.BeaconStateAccessors; import tech.pegasys.teku.spec.logic.common.helpers.MiscHelpers; import tech.pegasys.teku.spec.logic.common.util.AsyncBLSSignatureVerifier; @@ -153,13 +151,13 @@ public boolean verifyExecutionPayloadBidSignature( final BeaconState state, final SignedExecutionPayloadBid signedBid, final BLSSignatureVerifier signatureVerifier) { - final Builder builder = - BeaconStateGloas.required(state) - .getBuilders() - .get(signedBid.getMessage().getBuilderIndex().intValue()); + final BLSPublicKey publicKey = + beaconStateAccessors + .getBuilderPubKey(state, signedBid.getMessage().getBuilderIndex()) + .orElseThrow(); final Bytes signingRoot = calculateExecutionPayloadBidSigningRoot(state, signedBid.getMessage()); - return signatureVerifier.verify(builder.getPublicKey(), signingRoot, signedBid.getSignature()); + return signatureVerifier.verify(publicKey, signingRoot, signedBid.getSignature()); } private Bytes calculateExecutionPayloadBidSigningRoot( diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/gloas/block/BlockProcessorGloas.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/gloas/block/BlockProcessorGloas.java index 173a34f656b..ed529a481e1 100644 --- a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/gloas/block/BlockProcessorGloas.java +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/gloas/block/BlockProcessorGloas.java @@ -22,7 +22,6 @@ import java.util.function.Supplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import tech.pegasys.teku.bls.BLSSignatureVerifier; import tech.pegasys.teku.infrastructure.ssz.SszList; import tech.pegasys.teku.infrastructure.unsigned.UInt64; import tech.pegasys.teku.spec.cache.IndexedAttestationCache; @@ -293,7 +292,7 @@ public UInt64 processExecutionPayloadBid( throw new BlockProcessingException("Builder doesn't have funds to cover the bid"); } if (!operationSignatureVerifier.verifyExecutionPayloadBidSignature( - state, signedBid, BLSSignatureVerifier.SIMPLE)) { + state, signedBid, specConfigGloas.getBLSSignatureVerifier())) { throw new BlockProcessingException("Signature for the signed bind was invalid"); } } diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/BuilderBidFetcher.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/BuilderBidFetcher.java index 9d2ca3c852a..3c9f75c1012 100644 --- a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/BuilderBidFetcher.java +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/BuilderBidFetcher.java @@ -15,6 +15,7 @@ import static tech.pegasys.teku.infrastructure.logging.Converter.gweiToEth; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.stream.Stream; @@ -24,6 +25,7 @@ import tech.pegasys.teku.bls.BLSPublicKey; import tech.pegasys.teku.builder.rest.StakedBuilderClientProvider; import tech.pegasys.teku.infrastructure.async.SafeFuture; +import tech.pegasys.teku.infrastructure.ssz.SszList; import tech.pegasys.teku.infrastructure.unsigned.UInt64; import tech.pegasys.teku.spec.Spec; import tech.pegasys.teku.spec.datastructures.builder.versions.gloas.BuilderConfig; @@ -38,11 +40,15 @@ public class BuilderBidFetcher { private final Spec spec; private final StakedBuilderClientProvider stakedBuilderClientProvider; + private final BuilderBidValidator bidValidator; public BuilderBidFetcher( - final Spec spec, final StakedBuilderClientProvider stakedBuilderClientProvider) { + final Spec spec, + final StakedBuilderClientProvider stakedBuilderClientProvider, + final BuilderBidValidator bidValidator) { this.spec = spec; this.stakedBuilderClientProvider = stakedBuilderClientProvider; + this.bidValidator = bidValidator; } public SafeFuture> getBuilderBids( @@ -51,12 +57,16 @@ public SafeFuture> getBuilderBids( final BuilderConfig builderConfig, final Bytes32 parentHash, final Bytes32 parentRoot) { + final SszList configuredBuilders = builderConfig.getBuilders(); + if (configuredBuilders.isEmpty()) { + return SafeFuture.completedFuture(Collections.emptyList()); + } final int proposerIndex = spec.atSlot(slot).beaconStateAccessors().getBeaconProposerIndex(state, slot); final BLSPublicKey proposerPubkey = spec.getValidatorPubKey(state, UInt64.valueOf(proposerIndex)).orElseThrow(); final Stream>> builderBids = - builderConfig.getBuilders().stream() + configuredBuilders.stream() .map( builderEntry -> stakedBuilderClientProvider @@ -66,8 +76,7 @@ public SafeFuture> getBuilderBids( .thenApply( maybeBid -> maybeBid - // TODO-GLOAS: validate the builder bids - // https://github.com/Consensys/teku/issues/11191 + .filter(bid -> validateBid(bid, state)) .map(bid -> createRemoteBid(bid, builderEntry))) .whenComplete( (maybeBid, exception) -> { @@ -97,6 +106,18 @@ public SafeFuture> getBuilderBids( .thenApply(bids -> bids.stream().flatMap(Optional::stream).toList()); } + private boolean validateBid(final SignedExecutionPayloadBid bid, final BeaconState state) { + try { + return bidValidator.validateBid(bid, state); + } catch (final Exception ex) { + LOG.warn( + "Exception occurred while validating a bid from builder {}", + bid.getMessage().getBuilderIndex(), + ex); + return false; + } + } + private RemoteBid createRemoteBid( final SignedExecutionPayloadBid bid, final BuilderEntry builderEntry) { final UInt64 valueInGwei = diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidator.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidator.java new file mode 100644 index 00000000000..f71325a0fa1 --- /dev/null +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidator.java @@ -0,0 +1,126 @@ +/* + * Copyright Consensys Software Inc., 2026 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.teku.statetransition.execution; + +import java.util.Optional; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.Spec; +import tech.pegasys.teku.spec.SpecVersion; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ExecutionPayloadBid; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ProposerPreferences; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.SignedExecutionPayloadBid; +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.logic.versions.gloas.helpers.BeaconStateAccessorsGloas; +import tech.pegasys.teku.spec.logic.versions.gloas.helpers.PredicatesGloas; +import tech.pegasys.teku.statetransition.validation.ExecutionPayloadBidGossipValidator; + +public class BuilderBidValidator { + + private static final Logger LOG = LogManager.getLogger(); + + private final Spec spec; + private final ProposerPreferencesManager proposerPreferencesManager; + + public BuilderBidValidator( + final Spec spec, final ProposerPreferencesManager proposerPreferencesManager) { + this.spec = spec; + this.proposerPreferencesManager = proposerPreferencesManager; + } + + /** + * Validates a bid coming from the Builder API + * + *

Validating + * a SignedExecutionPayloadBid + * + * @param signedBid the signed bid to validate + * @param state the current beacon state + * @return true if the bid is valid, false otherwise + */ + public boolean validateBid(final SignedExecutionPayloadBid signedBid, final BeaconState state) { + final ExecutionPayloadBid bid = signedBid.getMessage(); + final UInt64 slot = bid.getSlot(); + final SpecVersion specVersion = spec.atSlot(slot); + + final PredicatesGloas predicates = PredicatesGloas.required(specVersion.predicates()); + final BeaconStateAccessorsGloas beaconStateAccessors = + BeaconStateAccessorsGloas.required(specVersion.beaconStateAccessors()); + final BeaconStateGloas stateGloas = BeaconStateGloas.required(state); + + if (!predicates.isActiveBuilder(state, bid.getBuilderIndex())) { + LOG.warn("Bid rejected: builder {} is not active", bid.getBuilderIndex()); + return false; + } + + if (!slot.equals(state.getSlot())) { + LOG.warn("Bid rejected: bid slot {} does not match state slot {}", slot, state.getSlot()); + return false; + } + + if (!bid.getParentBlockHash().equals(stateGloas.getLatestExecutionPayloadBid().getBlockHash()) + && !bid.getParentBlockHash().equals(stateGloas.getLatestBlockHash())) { + LOG.warn("Bid rejected: parent block hash does not extend a known parent"); + return false; + } + + if (!bid.getParentBlockRoot().equals(state.getLatestBlockHeader().hashTreeRoot())) { + LOG.warn("Bid rejected: parent block root mismatch"); + return false; + } + + if (!bid.getPrevRandao() + .equals( + beaconStateAccessors.getRandaoMix( + state, beaconStateAccessors.getCurrentEpoch(state)))) { + LOG.warn("Bid rejected: prev_randao mismatch"); + return false; + } + + final Optional proposerPreferences = + proposerPreferencesManager.getProposerPreferences(slot); + + if (proposerPreferences.isPresent()) { + if (bid.getFeeRecipient().equals(proposerPreferences.get().getFeeRecipient())) { + LOG.warn("Bid rejected: fee recipient mismatch"); + return false; + } + final UInt64 parentGasLimit = stateGloas.getLatestExecutionPayloadBid().getGasLimit(); + if (!ExecutionPayloadBidGossipValidator.isGasLimitTargetCompatible( + parentGasLimit, bid.getGasLimit(), proposerPreferences.get().getTargetGasLimit())) { + LOG.warn("Bid rejected: gas limit {} is not compatible with target", bid.getGasLimit()); + return false; + } + } + + if (bid.getValue().isGreaterThan(UInt64.ZERO) + && !beaconStateAccessors.canBuilderCoverBid(state, bid.getBuilderIndex(), bid.getValue())) { + LOG.warn("Bid rejected: builder {} cannot cover bid value", bid.getBuilderIndex()); + return false; + } + + if (!specVersion + .operationSignatureVerifier() + .verifyExecutionPayloadBidSignature( + state, signedBid, specVersion.getConfig().getBLSSignatureVerifier())) { + LOG.debug("Bid rejected: invalid signature"); + return false; + } + + return true; + } +} diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/ExecutionPayloadBidSelector.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/ExecutionPayloadBidSelector.java index 1186f782b00..56196ab5b31 100644 --- a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/ExecutionPayloadBidSelector.java +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/ExecutionPayloadBidSelector.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.tuweni.bytes.Bytes32; @@ -53,9 +54,9 @@ public ExecutionPayloadBidSelector( /** * Selects the highest-value bid from p2p and builder bids. P2P bids are filtered by parent root, - * parent block hash, min bid, and {@code isBuilderAllowed}; builder bids are filtered by {@code - * isBuilderAllowed} only because all validation is done during fetching the bids. On equal value, - * the builder bid is preferred. + * parent block hash, min bid, and {@code isBuilderAllowed}; builder bids are filtered by min bid + * and {@code isBuilderAllowed} only because all validation is done during fetching the bids. On + * equal value, the builder bid is preferred. */ public Optional selectBestRemoteBid( final Set p2pBids, @@ -64,6 +65,9 @@ public Optional selectBestRemoteBid( final Bytes32 parentBlockHash, final BeaconState state, final BuilderConfig builderConfig) { + // A remote bid is eligible only if `bid_score >= min_bid + final Predicate minBidPredicate = + bid -> bid.valueInGwei().isGreaterThanOrEqualTo(builderConfig.getMinBid()); final Optional bestP2PBid = p2pBids.stream() .filter(bid -> bid.bid().getMessage().getParentBlockRoot().equals(parentRoot)) @@ -71,8 +75,7 @@ public Optional selectBestRemoteBid( .filter( bid -> executionPayloadBidCircuitBreaker.isBuilderAllowed(bid.builderIndex(), state)) - // A bid is eligible only if `bid_score >= min_bid - .filter(bid -> bid.valueInGwei().isGreaterThanOrEqualTo(builderConfig.getMinBid())) + .filter(minBidPredicate) .max(REMOTE_BID_BY_VALUE_ASCENDING); final Optional bestBuilderBid = @@ -80,6 +83,7 @@ public Optional selectBestRemoteBid( .filter( bid -> executionPayloadBidCircuitBreaker.isBuilderAllowed(bid.builderIndex(), state)) + .filter(minBidPredicate) .max(REMOTE_BID_BY_VALUE_ASCENDING); if (bestBuilderBid.isEmpty()) { diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadBidGossipValidator.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadBidGossipValidator.java index 778c3b3f21b..df0c8afa87a 100644 --- a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadBidGossipValidator.java +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadBidGossipValidator.java @@ -489,7 +489,7 @@ private boolean isSignatureValid( state); } - static boolean isGasLimitTargetCompatible( + public static boolean isGasLimitTargetCompatible( final UInt64 parentGasLimit, final UInt64 gasLimit, final UInt64 targetGasLimit) { final UInt64 maxGasLimitDifference = parentGasLimit.dividedBy(1024).max(UInt64.ONE).minus(UInt64.ONE); diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/execution/BuilderBidFetcherTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/execution/BuilderBidFetcherTest.java index b7b50d0169b..b3aa05f230d 100644 --- a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/execution/BuilderBidFetcherTest.java +++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/execution/BuilderBidFetcherTest.java @@ -15,11 +15,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.List; import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import tech.pegasys.teku.builder.rest.StakedBuilderClient; import tech.pegasys.teku.builder.rest.StakedBuilderClientProvider; @@ -41,9 +43,15 @@ public class BuilderBidFetcherTest { private final StakedBuilderClientProvider stakedBuilderClientProvider = mock(StakedBuilderClientProvider.class); private final StakedBuilderClient builderClient = mock(StakedBuilderClient.class); + private final BuilderBidValidator bidValidator = mock(BuilderBidValidator.class); private final BuilderBidFetcher fetcher = - new BuilderBidFetcher(spec, stakedBuilderClientProvider); + new BuilderBidFetcher(spec, stakedBuilderClientProvider, bidValidator); + + @BeforeEach + void setUp() { + when(bidValidator.validateBid(any(), any())).thenReturn(true); + } @Test void returnsEmptyListWhenNoBuildersDefined() { @@ -89,6 +97,31 @@ void includesBidsFromConfiguredBuilders() { Optional.of(builderConfig.getBuilders().get(1).getUrl())); } + @Test + void excludesBuilderBidWhenValidationFails() { + final BeaconState state = dataStructureUtil.randomBeaconState(); + final SignedExecutionPayloadBid validBid = dataStructureUtil.randomSignedExecutionPayloadBid(); + final SignedExecutionPayloadBid invalidBid = + dataStructureUtil.randomSignedExecutionPayloadBid(); + final BuilderConfig builderConfig = dataStructureUtil.randomBuilderConfig(2); + when(stakedBuilderClientProvider.getClient(any())).thenReturn(builderClient); + when(builderClient.getExecutionPayloadBid(any(), any(), any(), any(), any())) + .thenReturn(SafeFuture.completedFuture(Optional.of(validBid))) + .thenReturn(SafeFuture.completedFuture(Optional.of(invalidBid))); + when(bidValidator.validateBid(eq(invalidBid), any())).thenReturn(false); + + final List result = + SafeFutureAssert.safeJoin( + fetcher.getBuilderBids( + state, + state.getSlot(), + builderConfig, + dataStructureUtil.randomBytes32(), + dataStructureUtil.randomBytes32())); + + assertThat(result).map(RemoteBid::bid).containsExactly(validBid); + } + @Test void excludesBuilderBidWhenRequestFails() { final BeaconState state = dataStructureUtil.randomBeaconState(); diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidatorTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidatorTest.java new file mode 100644 index 00000000000..7d353f02773 --- /dev/null +++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidatorTest.java @@ -0,0 +1,338 @@ +/* + * Copyright Consensys Software Inc., 2026 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.teku.statetransition.execution; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static tech.pegasys.teku.spec.config.SpecConfig.FAR_FUTURE_EPOCH; + +import java.util.List; +import java.util.Optional; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tech.pegasys.teku.bls.BLSSignatureVerifier; +import tech.pegasys.teku.ethereum.execution.types.Eth1Address; +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.datastructures.epbs.versions.gloas.ExecutionPayloadBid; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ExecutionPayloadBidSchema; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ProposerPreferences; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.SignedExecutionPayloadBid; +import tech.pegasys.teku.spec.datastructures.state.Checkpoint; +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.beaconstate.versions.gloas.BeaconStateSchemaGloas; +import tech.pegasys.teku.spec.datastructures.state.versions.gloas.Builder; +import tech.pegasys.teku.spec.logic.versions.gloas.helpers.BeaconStateAccessorsGloas; +import tech.pegasys.teku.spec.schemas.SchemaDefinitionsGloas; +import tech.pegasys.teku.spec.util.DataStructureUtil; + +public class BuilderBidValidatorTest { + + private static final UInt64 FINALIZED_EPOCH = UInt64.valueOf(5); + private static final UInt64 BUILDER_INDEX = UInt64.ZERO; + + // NOOP verifier so random signatures pass — lets tests focus on the other validation rules + private final Spec spec = + TestSpecFactory.createMinimalGloas( + config -> config.blsSignatureVerifier(BLSSignatureVerifier.NOOP)); + private final DataStructureUtil dataStructureUtil = new DataStructureUtil(spec); + private final ProposerPreferencesManager proposerPreferencesManager = + mock(ProposerPreferencesManager.class); + private final BuilderBidValidator validator = + new BuilderBidValidator(spec, proposerPreferencesManager); + + private BeaconStateGloas state; + private Bytes32 validParentBlockHash; + private Bytes32 validParentBlockRoot; + private Bytes32 validPrevRandao; + private UInt64 validGasLimit; + + @BeforeEach + void setUp() { + when(proposerPreferencesManager.getProposerPreferences(any())).thenReturn(Optional.empty()); + state = + createStateWithActiveBuilder(spec.getGenesisSpec().getConfig().getMaxEffectiveBalance()); + + final BeaconStateGloas stateGloas = BeaconStateGloas.required(state); + final BeaconStateAccessorsGloas accessors = + BeaconStateAccessorsGloas.required(spec.atSlot(state.getSlot()).beaconStateAccessors()); + + validParentBlockHash = stateGloas.getLatestExecutionPayloadBid().getBlockHash(); + validParentBlockRoot = state.getLatestBlockHeader().hashTreeRoot(); + validPrevRandao = accessors.getRandaoMix(state, accessors.getCurrentEpoch(state)); + validGasLimit = stateGloas.getLatestExecutionPayloadBid().getGasLimit(); + } + + @Test + void returnsTrueForValidBid() { + assertThat(validator.validateBid(validSignedBid(), state)).isTrue(); + } + + @Test + void rejectsIfBuilderIsNotActive() { + // Builder at index 1 does not exist — state only has a single builder at index 0 + final SignedExecutionPayloadBid bid = + signedBidWith( + BUILDER_INDEX.plus(1), + state.getSlot(), + UInt64.ZERO, + validParentBlockHash, + validParentBlockRoot, + validPrevRandao, + validGasLimit, + dataStructureUtil.randomEth1Address()); + assertThat(validator.validateBid(bid, state)).isFalse(); + } + + @Test + void rejectsIfSlotMismatch() { + final SignedExecutionPayloadBid bid = + signedBidWith( + BUILDER_INDEX, + state.getSlot().plus(1), + UInt64.ZERO, + validParentBlockHash, + validParentBlockRoot, + validPrevRandao, + validGasLimit, + dataStructureUtil.randomEth1Address()); + assertThat(validator.validateBid(bid, state)).isFalse(); + } + + @Test + void rejectsIfParentBlockHashDoesNotMatchEither() { + final SignedExecutionPayloadBid bid = + signedBidWith( + BUILDER_INDEX, + state.getSlot(), + UInt64.ZERO, + dataStructureUtil.randomBytes32(), + validParentBlockRoot, + validPrevRandao, + validGasLimit, + dataStructureUtil.randomEth1Address()); + assertThat(validator.validateBid(bid, state)).isFalse(); + } + + @Test + void acceptsBidWhoseParentBlockHashMatchesLatestBlockHash() { + final Bytes32 latestBlockHash = BeaconStateGloas.required(state).getLatestBlockHash(); + final SignedExecutionPayloadBid bid = + signedBidWith( + BUILDER_INDEX, + state.getSlot(), + UInt64.ZERO, + latestBlockHash, + validParentBlockRoot, + validPrevRandao, + validGasLimit, + dataStructureUtil.randomEth1Address()); + assertThat(validator.validateBid(bid, state)).isTrue(); + } + + @Test + void rejectsIfParentBlockRootMismatch() { + final SignedExecutionPayloadBid bid = + signedBidWith( + BUILDER_INDEX, + state.getSlot(), + UInt64.ZERO, + validParentBlockHash, + dataStructureUtil.randomBytes32(), + validPrevRandao, + validGasLimit, + dataStructureUtil.randomEth1Address()); + assertThat(validator.validateBid(bid, state)).isFalse(); + } + + @Test + void rejectsIfPrevRandaoMismatch() { + final SignedExecutionPayloadBid bid = + signedBidWith( + BUILDER_INDEX, + state.getSlot(), + UInt64.ZERO, + validParentBlockHash, + validParentBlockRoot, + dataStructureUtil.randomBytes32(), + validGasLimit, + dataStructureUtil.randomEth1Address()); + assertThat(validator.validateBid(bid, state)).isFalse(); + } + + @Test + void rejectsWhenFeeRecipientMatchesProposerPreferences() { + // Note: the current implementation rejects when fee recipient MATCHES the proposer's + // preference. + // The spec requires rejection when they do NOT match — this condition is inverted. + final Eth1Address feeRecipient = dataStructureUtil.randomEth1Address(); + when(proposerPreferencesManager.getProposerPreferences(state.getSlot())) + .thenReturn(Optional.of(createProposerPreferences(feeRecipient, validGasLimit))); + + final SignedExecutionPayloadBid bid = + signedBidWith( + BUILDER_INDEX, + state.getSlot(), + UInt64.ZERO, + validParentBlockHash, + validParentBlockRoot, + validPrevRandao, + validGasLimit, + feeRecipient); + assertThat(validator.validateBid(bid, state)).isFalse(); + } + + @Test + void rejectsIfGasLimitNotCompatibleWithProposerPreferences() { + // Use a different fee recipient in the bid so the (currently inverted) fee check passes + final Eth1Address bidFeeRecipient = dataStructureUtil.randomEth1Address(); + final Eth1Address preferencesFeeRecipient = dataStructureUtil.randomEth1Address(); + // Target gas limit far out of the compatible range forces a specific adjusted value + final UInt64 incompatibleTargetGasLimit = validGasLimit.plus(1_000_000); + when(proposerPreferencesManager.getProposerPreferences(state.getSlot())) + .thenReturn( + Optional.of( + createProposerPreferences(preferencesFeeRecipient, incompatibleTargetGasLimit))); + + // Bid gas limit equals the parent gas limit but the required value (capped at max) differs + final SignedExecutionPayloadBid bid = + signedBidWith( + BUILDER_INDEX, + state.getSlot(), + UInt64.ZERO, + validParentBlockHash, + validParentBlockRoot, + validPrevRandao, + validGasLimit, + bidFeeRecipient); + assertThat(validator.validateBid(bid, state)).isFalse(); + } + + @Test + void rejectsIfBuilderCannotCoverBidValue() { + // Builder has zero balance — below MIN_DEPOSIT_AMOUNT, so it cannot cover any positive bid + final BeaconState lowBalanceState = createStateWithActiveBuilder(UInt64.ZERO); + final BeaconStateGloas stateGloas = BeaconStateGloas.required(lowBalanceState); + final BeaconStateAccessorsGloas beaconStateAccessors = + BeaconStateAccessorsGloas.required( + spec.atSlot(lowBalanceState.getSlot()).beaconStateAccessors()); + + final SignedExecutionPayloadBid bid = + signedBidWith( + BUILDER_INDEX, + lowBalanceState.getSlot(), + UInt64.ONE, + stateGloas.getLatestExecutionPayloadBid().getBlockHash(), + lowBalanceState.getLatestBlockHeader().hashTreeRoot(), + beaconStateAccessors.getRandaoMix( + lowBalanceState, beaconStateAccessors.getCurrentEpoch(lowBalanceState)), + stateGloas.getLatestExecutionPayloadBid().getGasLimit(), + dataStructureUtil.randomEth1Address()); + assertThat(validator.validateBid(bid, lowBalanceState)).isFalse(); + } + + @Test + void skipsFeeAndGasLimitChecksWhenProposerPreferencesAbsent() { + when(proposerPreferencesManager.getProposerPreferences(any())).thenReturn(Optional.empty()); + assertThat(validator.validateBid(validSignedBid(), state)).isTrue(); + } + + private SignedExecutionPayloadBid validSignedBid() { + return signedBidWith( + BUILDER_INDEX, + state.getSlot(), + UInt64.ZERO, + validParentBlockHash, + validParentBlockRoot, + validPrevRandao, + validGasLimit, + dataStructureUtil.randomEth1Address()); + } + + private SignedExecutionPayloadBid signedBidWith( + final UInt64 builderIndex, + final UInt64 slot, + final UInt64 value, + final Bytes32 parentBlockHash, + final Bytes32 parentBlockRoot, + final Bytes32 prevRandao, + final UInt64 gasLimit, + final Eth1Address feeRecipient) { + final SchemaDefinitionsGloas schemaDefinitions = + SchemaDefinitionsGloas.required(spec.atSlot(slot).getSchemaDefinitions()); + final ExecutionPayloadBidSchema schema = schemaDefinitions.getExecutionPayloadBidSchema(); + final ExecutionPayloadBid bid = + schema.create( + parentBlockHash, + parentBlockRoot, + dataStructureUtil.randomBytes32(), + prevRandao, + feeRecipient, + gasLimit, + builderIndex, + slot, + value, + UInt64.ZERO, + schema.getBlobKzgCommitmentsSchema().createFromElements(List.of()), + dataStructureUtil.randomBytes32()); + return schemaDefinitions + .getSignedExecutionPayloadBidSchema() + .create(bid, dataStructureUtil.randomSignature()); + } + + private ProposerPreferences createProposerPreferences( + final Eth1Address feeRecipient, final UInt64 targetGasLimit) { + final SchemaDefinitionsGloas schemaDefinitions = + SchemaDefinitionsGloas.required(spec.atSlot(state.getSlot()).getSchemaDefinitions()); + return schemaDefinitions + .getProposerPreferencesSchema() + .create( + dataStructureUtil.randomBytes32(), + state.getSlot(), + dataStructureUtil.randomUInt64(), + feeRecipient, + targetGasLimit); + } + + private BeaconStateGloas createStateWithActiveBuilder(final UInt64 builderBalance) { + final UInt64 slot = + FINALIZED_EPOCH.times(spec.getGenesisSpec().getConfig().getSlotsPerEpoch()).plus(1); + + final BeaconStateSchemaGloas stateSchema = + BeaconStateSchemaGloas.required( + spec.forMilestone(SpecMilestone.GLOAS).getSchemaDefinitions().getBeaconStateSchema()); + + final Builder activeBuilder = + dataStructureUtil + .builderBuilder() + .depositEpoch(UInt64.ZERO) + .withdrawableEpoch(FAR_FUTURE_EPOCH) + .balance(builderBalance) + .build(); + + return dataStructureUtil + .stateBuilderGloas(10, 0, 10) + .builders(stateSchema.getBuildersSchema().createFromElements(List.of(activeBuilder))) + .slot(slot) + // stubbing the finalized checkpoint, because builder needs to be active + .finalizedCheckpoint(new Checkpoint(FINALIZED_EPOCH, dataStructureUtil.randomBytes32())) + .build(); + } +} diff --git a/services/beaconchain/src/main/java/tech/pegasys/teku/services/beaconchain/BeaconChainController.java b/services/beaconchain/src/main/java/tech/pegasys/teku/services/beaconchain/BeaconChainController.java index 427608b210d..8a4bb435101 100644 --- a/services/beaconchain/src/main/java/tech/pegasys/teku/services/beaconchain/BeaconChainController.java +++ b/services/beaconchain/src/main/java/tech/pegasys/teku/services/beaconchain/BeaconChainController.java @@ -208,6 +208,7 @@ import tech.pegasys.teku.statetransition.datacolumns.retriever.recovering.SidecarRetriever; import tech.pegasys.teku.statetransition.datacolumns.util.SuperNodeSupplier; import tech.pegasys.teku.statetransition.execution.BuilderBidFetcher; +import tech.pegasys.teku.statetransition.execution.BuilderBidValidator; import tech.pegasys.teku.statetransition.execution.DefaultExecutionPayloadBidManager; import tech.pegasys.teku.statetransition.execution.DefaultExecutionPayloadManager; import tech.pegasys.teku.statetransition.execution.DefaultProposerPreferencesManager; @@ -1027,8 +1028,10 @@ protected void initExecutionPayloadBidManager() { .create(recentChainData::getForkChoiceStrategy); final StakedBuilderClientProvider stakedBuilderClientProvider = new StakedBuilderClientProvider(spec, beaconAsyncRunner); + final BuilderBidValidator bidValidator = + new BuilderBidValidator(spec, proposerPreferencesManager); final BuilderBidFetcher builderBidFetcher = - new BuilderBidFetcher(spec, stakedBuilderClientProvider); + new BuilderBidFetcher(spec, stakedBuilderClientProvider, bidValidator); final ExecutionPayloadBidSelector executionPayloadBidSelector = new ExecutionPayloadBidSelector( beaconConfig.executionLayerConfig().getUseShouldOverrideBuilderFlag(), From 98f0da4fc54e0a4399d39aa5e1316751bfc9c380 Mon Sep 17 00:00:00 2001 From: StefanBratanov Date: Thu, 10 Sep 2026 19:52:58 +0300 Subject: [PATCH 2/4] small nit --- .../operations/OperationSignatureVerifier.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/operations/OperationSignatureVerifier.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/operations/OperationSignatureVerifier.java index d8ec4b57501..0a47b806873 100644 --- a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/operations/OperationSignatureVerifier.java +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/operations/OperationSignatureVerifier.java @@ -33,6 +33,7 @@ import tech.pegasys.teku.spec.datastructures.operations.VoluntaryExit; import tech.pegasys.teku.spec.datastructures.state.Fork; 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.logic.common.helpers.BeaconStateAccessors; import tech.pegasys.teku.spec.logic.common.helpers.MiscHelpers; import tech.pegasys.teku.spec.logic.common.util.AsyncBLSSignatureVerifier; @@ -151,13 +152,14 @@ public boolean verifyExecutionPayloadBidSignature( final BeaconState state, final SignedExecutionPayloadBid signedBid, final BLSSignatureVerifier signatureVerifier) { - final BLSPublicKey publicKey = - beaconStateAccessors - .getBuilderPubKey(state, signedBid.getMessage().getBuilderIndex()) - .orElseThrow(); + final BLSPublicKey builderPubkey = + BeaconStateGloas.required(state) + .getBuilders() + .get(signedBid.getMessage().getBuilderIndex().intValue()) + .getPublicKey(); final Bytes signingRoot = calculateExecutionPayloadBidSigningRoot(state, signedBid.getMessage()); - return signatureVerifier.verify(publicKey, signingRoot, signedBid.getSignature()); + return signatureVerifier.verify(builderPubkey, signingRoot, signedBid.getSignature()); } private Bytes calculateExecutionPayloadBidSigningRoot( From 06ce25ffe26c41df46825a1a4f34909ba4e33048 Mon Sep 17 00:00:00 2001 From: StefanBratanov Date: Thu, 10 Sep 2026 19:56:28 +0300 Subject: [PATCH 3/4] very big nit --- .../spec/logic/versions/gloas/block/BlockProcessorGloas.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/gloas/block/BlockProcessorGloas.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/gloas/block/BlockProcessorGloas.java index ed529a481e1..21d83052019 100644 --- a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/gloas/block/BlockProcessorGloas.java +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/gloas/block/BlockProcessorGloas.java @@ -292,7 +292,7 @@ public UInt64 processExecutionPayloadBid( throw new BlockProcessingException("Builder doesn't have funds to cover the bid"); } if (!operationSignatureVerifier.verifyExecutionPayloadBidSignature( - state, signedBid, specConfigGloas.getBLSSignatureVerifier())) { + state, signedBid, specConfig.getBLSSignatureVerifier())) { throw new BlockProcessingException("Signature for the signed bind was invalid"); } } From 9b822b497079220a634a99848182f5cf641b2c5b Mon Sep 17 00:00:00 2001 From: StefanBratanov Date: Thu, 10 Sep 2026 20:25:37 +0300 Subject: [PATCH 4/4] cursor feedback --- .../execution/BuilderBidValidator.java | 20 ++++++++++++++--- .../execution/BuilderBidValidatorTest.java | 22 +++++++++++-------- .../beaconchain/BeaconChainController.java | 2 +- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidator.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidator.java index f71325a0fa1..b87b6c743d9 100644 --- a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidator.java +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidator.java @@ -27,6 +27,7 @@ import tech.pegasys.teku.spec.logic.versions.gloas.helpers.BeaconStateAccessorsGloas; import tech.pegasys.teku.spec.logic.versions.gloas.helpers.PredicatesGloas; import tech.pegasys.teku.statetransition.validation.ExecutionPayloadBidGossipValidator; +import tech.pegasys.teku.storage.client.RecentChainData; public class BuilderBidValidator { @@ -34,11 +35,15 @@ public class BuilderBidValidator { private final Spec spec; private final ProposerPreferencesManager proposerPreferencesManager; + private final RecentChainData recentChainData; public BuilderBidValidator( - final Spec spec, final ProposerPreferencesManager proposerPreferencesManager) { + final Spec spec, + final ProposerPreferencesManager proposerPreferencesManager, + final RecentChainData recentChainData) { this.spec = spec; this.proposerPreferencesManager = proposerPreferencesManager; + this.recentChainData = recentChainData; } /** @@ -95,11 +100,20 @@ public boolean validateBid(final SignedExecutionPayloadBid signedBid, final Beac proposerPreferencesManager.getProposerPreferences(slot); if (proposerPreferences.isPresent()) { - if (bid.getFeeRecipient().equals(proposerPreferences.get().getFeeRecipient())) { + if (!bid.getFeeRecipient().equals(proposerPreferences.get().getFeeRecipient())) { LOG.warn("Bid rejected: fee recipient mismatch"); return false; } - final UInt64 parentGasLimit = stateGloas.getLatestExecutionPayloadBid().getGasLimit(); + final UInt64 parentGasLimit = + recentChainData + .getExecutionGasLimitForBlockRootAndHash( + bid.getParentBlockRoot(), bid.getParentBlockHash()) + .orElseThrow( + () -> + new IllegalStateException( + String.format( + "Parent gas limit for block root %s and hash %s is not available", + bid.getParentBlockRoot(), bid.getParentBlockHash()))); if (!ExecutionPayloadBidGossipValidator.isGasLimitTargetCompatible( parentGasLimit, bid.getGasLimit(), proposerPreferences.get().getTargetGasLimit())) { LOG.warn("Bid rejected: gas limit {} is not compatible with target", bid.getGasLimit()); diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidatorTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidatorTest.java index 7d353f02773..3dad7e0bece 100644 --- a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidatorTest.java +++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/execution/BuilderBidValidatorTest.java @@ -42,6 +42,7 @@ import tech.pegasys.teku.spec.logic.versions.gloas.helpers.BeaconStateAccessorsGloas; import tech.pegasys.teku.spec.schemas.SchemaDefinitionsGloas; import tech.pegasys.teku.spec.util.DataStructureUtil; +import tech.pegasys.teku.storage.client.RecentChainData; public class BuilderBidValidatorTest { @@ -55,8 +56,9 @@ public class BuilderBidValidatorTest { private final DataStructureUtil dataStructureUtil = new DataStructureUtil(spec); private final ProposerPreferencesManager proposerPreferencesManager = mock(ProposerPreferencesManager.class); + private final RecentChainData recentChainData = mock(RecentChainData.class); private final BuilderBidValidator validator = - new BuilderBidValidator(spec, proposerPreferencesManager); + new BuilderBidValidator(spec, proposerPreferencesManager, recentChainData); private BeaconStateGloas state; private Bytes32 validParentBlockHash; @@ -71,13 +73,17 @@ void setUp() { createStateWithActiveBuilder(spec.getGenesisSpec().getConfig().getMaxEffectiveBalance()); final BeaconStateGloas stateGloas = BeaconStateGloas.required(state); - final BeaconStateAccessorsGloas accessors = + final BeaconStateAccessorsGloas beaconStateAccessors = BeaconStateAccessorsGloas.required(spec.atSlot(state.getSlot()).beaconStateAccessors()); validParentBlockHash = stateGloas.getLatestExecutionPayloadBid().getBlockHash(); validParentBlockRoot = state.getLatestBlockHeader().hashTreeRoot(); - validPrevRandao = accessors.getRandaoMix(state, accessors.getCurrentEpoch(state)); + validPrevRandao = + beaconStateAccessors.getRandaoMix(state, beaconStateAccessors.getCurrentEpoch(state)); validGasLimit = stateGloas.getLatestExecutionPayloadBid().getGasLimit(); + + when(recentChainData.getExecutionGasLimitForBlockRootAndHash(any(), any())) + .thenReturn(Optional.of(validGasLimit)); } @Test @@ -178,13 +184,12 @@ void rejectsIfPrevRandaoMismatch() { } @Test - void rejectsWhenFeeRecipientMatchesProposerPreferences() { - // Note: the current implementation rejects when fee recipient MATCHES the proposer's - // preference. - // The spec requires rejection when they do NOT match — this condition is inverted. + void rejectsWhenFeeRecipientDoesNotMatchProposerPreferences() { final Eth1Address feeRecipient = dataStructureUtil.randomEth1Address(); when(proposerPreferencesManager.getProposerPreferences(state.getSlot())) - .thenReturn(Optional.of(createProposerPreferences(feeRecipient, validGasLimit))); + .thenReturn( + Optional.of( + createProposerPreferences(dataStructureUtil.randomEth1Address(), validGasLimit))); final SignedExecutionPayloadBid bid = signedBidWith( @@ -201,7 +206,6 @@ void rejectsWhenFeeRecipientMatchesProposerPreferences() { @Test void rejectsIfGasLimitNotCompatibleWithProposerPreferences() { - // Use a different fee recipient in the bid so the (currently inverted) fee check passes final Eth1Address bidFeeRecipient = dataStructureUtil.randomEth1Address(); final Eth1Address preferencesFeeRecipient = dataStructureUtil.randomEth1Address(); // Target gas limit far out of the compatible range forces a specific adjusted value diff --git a/services/beaconchain/src/main/java/tech/pegasys/teku/services/beaconchain/BeaconChainController.java b/services/beaconchain/src/main/java/tech/pegasys/teku/services/beaconchain/BeaconChainController.java index 8a4bb435101..22339b98f10 100644 --- a/services/beaconchain/src/main/java/tech/pegasys/teku/services/beaconchain/BeaconChainController.java +++ b/services/beaconchain/src/main/java/tech/pegasys/teku/services/beaconchain/BeaconChainController.java @@ -1029,7 +1029,7 @@ protected void initExecutionPayloadBidManager() { final StakedBuilderClientProvider stakedBuilderClientProvider = new StakedBuilderClientProvider(spec, beaconAsyncRunner); final BuilderBidValidator bidValidator = - new BuilderBidValidator(spec, proposerPreferencesManager); + new BuilderBidValidator(spec, proposerPreferencesManager, recentChainData); final BuilderBidFetcher builderBidFetcher = new BuilderBidFetcher(spec, stakedBuilderClientProvider, bidValidator); final ExecutionPayloadBidSelector executionPayloadBidSelector =