Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
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;
Expand Down Expand Up @@ -153,13 +152,14 @@ public boolean verifyExecutionPayloadBidSignature(
final BeaconState state,
final SignedExecutionPayloadBid signedBid,
final BLSSignatureVerifier signatureVerifier) {
final Builder builder =
final BLSPublicKey builderPubkey =
BeaconStateGloas.required(state)
.getBuilders()
.get(signedBid.getMessage().getBuilderIndex().intValue());
.get(signedBid.getMessage().getBuilderIndex().intValue())
.getPublicKey();
final Bytes signingRoot =
calculateExecutionPayloadBidSigningRoot(state, signedBid.getMessage());
return signatureVerifier.verify(builder.getPublicKey(), signingRoot, signedBid.getSignature());
return signatureVerifier.verify(builderPubkey, signingRoot, signedBid.getSignature());
}

private Bytes calculateExecutionPayloadBidSigningRoot(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, specConfig.getBLSSignatureVerifier())) {
throw new BlockProcessingException("Signature for the signed bind was invalid");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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<List<RemoteBid>> getBuilderBids(
Expand All @@ -51,12 +57,16 @@ public SafeFuture<List<RemoteBid>> getBuilderBids(
final BuilderConfig builderConfig,
final Bytes32 parentHash,
final Bytes32 parentRoot) {
final SszList<BuilderEntry> 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<SafeFuture<Optional<RemoteBid>>> builderBids =
builderConfig.getBuilders().stream()
configuredBuilders.stream()
.map(
builderEntry ->
stakedBuilderClientProvider
Expand All @@ -66,8 +76,7 @@ public SafeFuture<List<RemoteBid>> 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) -> {
Expand Down Expand Up @@ -97,6 +106,18 @@ public SafeFuture<List<RemoteBid>> 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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -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
*
* <p><a
* href="https://github.com/ethereum/builder-specs/blob/main/specs/gloas/validator.md#validating-a-signedexecutionpayloadbid">Validating
* a SignedExecutionPayloadBid</a>
*
* @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> proposerPreferences =
proposerPreferencesManager.getProposerPreferences(slot);

if (proposerPreferences.isPresent()) {
if (bid.getFeeRecipient().equals(proposerPreferences.get().getFeeRecipient())) {
LOG.warn("Bid rejected: fee recipient mismatch");
return false;
}
Comment thread
cursor[bot] marked this conversation as resolved.
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;
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<RemoteBid> selectBestRemoteBid(
final Set<RemoteBid> p2pBids,
Expand All @@ -64,22 +65,25 @@ public Optional<RemoteBid> selectBestRemoteBid(
final Bytes32 parentBlockHash,
final BeaconState state,
final BuilderConfig builderConfig) {
// A remote bid is eligible only if `bid_score >= min_bid
final Predicate<RemoteBid> minBidPredicate =
bid -> bid.valueInGwei().isGreaterThanOrEqualTo(builderConfig.getMinBid());
final Optional<RemoteBid> bestP2PBid =
p2pBids.stream()
.filter(bid -> bid.bid().getMessage().getParentBlockRoot().equals(parentRoot))
.filter(bid -> bid.bid().getMessage().getParentBlockHash().equals(parentBlockHash))
.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<RemoteBid> bestBuilderBid =
builderBids.stream()
.filter(
bid ->
executionPayloadBidCircuitBreaker.isBuilderAllowed(bid.builderIndex(), state))
.filter(minBidPredicate)
.max(REMOTE_BID_BY_VALUE_ASCENDING);

if (bestBuilderBid.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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() {
Expand Down Expand Up @@ -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<RemoteBid> 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();
Expand Down
Loading
Loading