Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export async function importExecutionPayload(
blockRootHex,
blockHashHex,
envelope.payload.blockNumber,
envelope.payload.gasLimit,
execStatus,
dataAvailabilityStatus
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export enum ExecutionPayloadBidErrorCode {
BID_TOO_HIGH = "EXECUTION_PAYLOAD_BID_ERROR_BID_TOO_HIGH",
TOO_MANY_KZG_COMMITMENTS = "EXECUTION_PAYLOAD_BID_ERROR_TOO_MANY_KZG_COMMITMENTS",
UNKNOWN_BLOCK_ROOT = "EXECUTION_PAYLOAD_BID_ERROR_UNKNOWN_BLOCK_ROOT",
UNKNOWN_PARENT_BLOCK_HASH = "EXECUTION_PAYLOAD_BID_ERROR_UNKNOWN_PARENT_BLOCK_HASH",
INVALID_SLOT = "EXECUTION_PAYLOAD_BID_ERROR_INVALID_SLOT",
INVALID_SIGNATURE = "EXECUTION_PAYLOAD_BID_ERROR_INVALID_SIGNATURE",
NO_MATCHING_PROPOSER_PREFERENCES = "EXECUTION_PAYLOAD_BID_ERROR_NO_MATCHING_PROPOSER_PREFERENCES",
Expand Down Expand Up @@ -38,6 +39,7 @@ export type ExecutionPayloadBidErrorType =
commitmentLimit: number;
}
| {code: ExecutionPayloadBidErrorCode.UNKNOWN_BLOCK_ROOT; parentBlockRoot: RootHex}
| {code: ExecutionPayloadBidErrorCode.UNKNOWN_PARENT_BLOCK_HASH; parentBlockHash: RootHex}
| {code: ExecutionPayloadBidErrorCode.INVALID_SLOT; builderIndex: BuilderIndex; slot: Slot}
| {code: ExecutionPayloadBidErrorCode.INVALID_SIGNATURE; builderIndex: BuilderIndex; slot: Slot}
| {
Expand All @@ -56,7 +58,8 @@ export type ExecutionPayloadBidErrorType =
code: ExecutionPayloadBidErrorCode.PROPOSER_PREFERENCES_GAS_LIMIT_MISMATCH;
builderIndex: BuilderIndex;
bidGasLimit: number;
expectedGasLimit: number;
parentGasLimit: number;
targetGasLimit: number;
};

export class ExecutionPayloadBidError extends GossipActionError<ExecutionPayloadBidErrorType> {}
18 changes: 14 additions & 4 deletions packages/beacon-node/src/chain/forkChoice/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,11 @@ export function initializeForkChoiceFromFinalizedState(
executionPayloadBlockHash: isStatePostGloas(state)
? toRootHex(state.latestBlockHash)
: toRootHex(state.latestExecutionPayloadHeader.blockHash),
// TODO GLOAS: executionPayloadNumber is not tracked in BeaconState post-gloas (EIP-7732 removed
// latestExecutionPayloadHeader). Using 0 as unavailable fallback until a solution is found.
// TODO GLOAS: executionPayloadNumber/GasLimit are not tracked in BeaconState post-gloas
// (EIP-7732 removed latestExecutionPayloadHeader). Using 0 as unavailable fallback —
// see initializeForkChoiceFromUnfinalizedState for the same caveat on validation.
executionPayloadNumber: isStatePostGloas(state) ? 0 : state.payloadBlockNumber,
executionPayloadGasLimit: isStatePostGloas(state) ? 0 : state.latestExecutionPayloadHeader.gasLimit,
Comment on lines +143 to +147

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am bit confused by this comment, why is it not tracked in the state?

it's in state.latestExecutionPayloadBid.gasLimit, or is there a reason this value is not viable to use?

executionStatus: blockHeader.slot === GENESIS_SLOT ? ExecutionStatus.Valid : ExecutionStatus.Syncing,
}
: {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}),
Expand Down Expand Up @@ -232,9 +234,17 @@ export function initializeForkChoiceFromUnfinalizedState(
executionPayloadBlockHash: isStatePostGloas(unfinalizedState)
? toRootHex(unfinalizedState.latestBlockHash)
: toRootHex(unfinalizedState.latestExecutionPayloadHeader.blockHash),
// TODO GLOAS: executionPayloadNumber is not tracked in BeaconState post-gloas (EIP-7732 removed
// latestExecutionPayloadHeader). Using 0 as unavailable fallback until a solution is found.
// TODO GLOAS: executionPayloadNumber/GasLimit are not tracked in BeaconState post-gloas
// (EIP-7732 removed latestExecutionPayloadHeader). Using 0 as unavailable fallback until
// a solution is found. The 0 doesn't gate validation in practice: at boot the head's
// PENDING variant's `executionPayloadBlockHash` is the *parent's* payload hash (per the
// PENDING/EMPTY convention), so gossip bids that reference the head's *own* payload
// hash won't match this variant anyway and will IGNORE until `onExecutionPayload`
// upgrades the head to FULL with real values.
executionPayloadNumber: isStatePostGloas(unfinalizedState) ? 0 : unfinalizedState.payloadBlockNumber,
executionPayloadGasLimit: isStatePostGloas(unfinalizedState)
? 0
: unfinalizedState.latestExecutionPayloadHeader.gasLimit,
executionStatus: blockHeader.slot === GENESIS_SLOT ? ExecutionStatus.Valid : ExecutionStatus.Syncing,
}
: {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}),
Expand Down
41 changes: 25 additions & 16 deletions packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import {
G2_POINT_AT_INFINITY,
IBeaconStateView,
type IBeaconStateViewBellatrix,
type IBeaconStateViewGloas,
computeEpochAtSlot,
computeTimeAtSlot,
getExpectedGasLimit,
isStatePostBellatrix,
isStatePostCapella,
isStatePostGloas,
Expand Down Expand Up @@ -53,13 +53,7 @@ import {
import {Logger, byteArrayEquals, fromHex, sleep, toHex, toPubkeyHex, toRootHex} from "@lodestar/utils";
import {ZERO_HASH_HEX} from "../../constants/index.js";
import {numToQuantity} from "../../execution/engine/utils.js";
import {
IExecutionBuilder,
IExecutionEngine,
PayloadAttributes,
PayloadId,
getExpectedGasLimit,
} from "../../execution/index.js";
import {IExecutionBuilder, IExecutionEngine, PayloadAttributes, PayloadId} from "../../execution/index.js";
import {getShufflingDependentRoot} from "../../util/dependentRoot.js";
import {fromGraffitiBytes} from "../../util/graffiti.js";
import {kzg} from "../../util/kzg.js";
Expand Down Expand Up @@ -331,6 +325,7 @@ export async function produceBlockBody<T extends BlockType>(
fetchedTime,
executionBlockHash: toRootHex(executionPayload.blockHash),
blobs: blobsBundle.commitments.length,
gasLimit: executionPayload.gasLimit,
});

Object.assign(logMeta, {
Expand Down Expand Up @@ -869,9 +864,9 @@ function preparePayloadAttributes(
(payloadAttributes as gloas.SSEPayloadAttributes["payloadAttributes"]).slotNumber = prepareSlot;
(payloadAttributes as gloas.SSEPayloadAttributes["payloadAttributes"]).targetGasLimit = getProposerTargetGasLimit(
chain,
prepareState,
prepareSlot,
parentBlockRoot
parentBlockRoot,
parentBlockHash
);
}

Expand All @@ -886,14 +881,20 @@ function preparePayloadAttributes(
* (same `(slot, dependent_root)` lookup as gossip bid validation). When no matching
* preferences are pooled, target the parent payload's gas limit so the gas limit stays
* unchanged (`is_gas_limit_target_compatible` then requires `gas_limit == parent_gas_limit`).
*
* The parent payload's gas_limit is read from fork choice — the variant matching
* `(parentBlockRoot, parentBlockHash)` carries the correct value for both FULL parents
* (FULL.executionPayloadGasLimit = delivered payload's gas_limit) and EMPTY parents
* (EMPTY.executionPayloadGasLimit = inherited grandparent's gas_limit).
*/
function getProposerTargetGasLimit(
chain: {forkChoice: IForkChoice; proposerPreferencesPool: ProposerPreferencesPool},
state: IBeaconStateViewGloas,
prepareSlot: Slot,
parentBlockRoot: Root
parentBlockRoot: Root,
parentBlockHash: Bytes32
): number {
const parentBlock = chain.forkChoice.getBlockHexDefaultStatus(toRootHex(parentBlockRoot));
const parentBlockRootHex = toRootHex(parentBlockRoot);
const parentBlock = chain.forkChoice.getBlockHexDefaultStatus(parentBlockRootHex);
const dependentRootHex = (() => {
if (parentBlock === null) {
return null;
Expand All @@ -911,9 +912,17 @@ function getProposerTargetGasLimit(
})();

const pref = dependentRootHex !== null ? chain.proposerPreferencesPool.get(prepareSlot, dependentRootHex) : null;
// TODO GLOAS: state.latestExecutionPayloadBid is the latest *bid*, not the latest *executed*
// payload — for EMPTY parents this drifts. Consider having a default value like Prysm's DefaultBuilderGasLimit.
return Number(pref ? pref.message.targetGasLimit : state.latestExecutionPayloadBid.gasLimit);
if (pref !== null) {
return pref.message.targetGasLimit;
}

const parentPayloadVariant = chain.forkChoice.getBlockHexAndBlockHash(parentBlockRootHex, toRootHex(parentBlockHash));
if (parentPayloadVariant === null || parentPayloadVariant.executionPayloadBlockHash === null) {
throw new Error(
`Cannot resolve parent payload gas_limit for proposer targetGasLimit fallback parentBlockRoot=${parentBlockRootHex} parentBlockHash=${toRootHex(parentBlockHash)}`
);
}
return parentPayloadVariant.executionPayloadGasLimit;
}

export async function produceCommonBlockBody<T extends BlockType>(
Expand Down
42 changes: 30 additions & 12 deletions packages/beacon-node/src/chain/validation/executionPayloadBid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import {
createSingleSignatureSetFromComponents,
getExecutionPayloadBidSigningRoot,
isActiveBuilder,
isGasLimitTargetCompatible,
isStatePostGloas,
} from "@lodestar/state-transition";
import {gloas} from "@lodestar/types";
import {ValidatorIndex, gloas} from "@lodestar/types";
import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils";
import {getShufflingDependentRoot} from "../../util/dependentRoot.js";
import {ExecutionPayloadBidError, ExecutionPayloadBidErrorCode, GossipAction} from "../errors/index.js";
Expand All @@ -16,21 +17,21 @@ import {RegenCaller} from "../regen/index.js";
export async function validateApiExecutionPayloadBid(
chain: IBeaconChain,
signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid
): Promise<void> {
): Promise<{proposerIndex: ValidatorIndex}> {
return validateExecutionPayloadBid(chain, signedExecutionPayloadBid);
}

export async function validateGossipExecutionPayloadBid(
chain: IBeaconChain,
signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid
): Promise<void> {
): Promise<{proposerIndex: ValidatorIndex}> {
return validateExecutionPayloadBid(chain, signedExecutionPayloadBid);
}

async function validateExecutionPayloadBid(
chain: IBeaconChain,
signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid
): Promise<void> {
): Promise<{proposerIndex: ValidatorIndex}> {
const bid = signedExecutionPayloadBid.message;
const parentBlockRootHex = toRootHex(bid.parentBlockRoot);
const parentBlockHashHex = toRootHex(bid.parentBlockHash);
Expand Down Expand Up @@ -128,14 +129,33 @@ async function validateExecutionPayloadBid(
});
}

// [REJECT] `bid.gas_limit == proposer_preferences.target_gas_limit`.
// [IGNORE] `bid.parent_block_hash` is the block hash of a known execution payload in fork
// choice. Looks up the variant of `bid.parent_block_root` whose payload hash matches
// `bid.parent_block_hash` — works for both FULL parents (FULL variant carries the delivered
// payload's hash) and EMPTY parents (EMPTY/PENDING variants carry the inherited parent
// payload's hash, since the new block doesn't have its own payload). Variant carries the
// executed payload's gas_limit, which we use as `parent_gas_limit` below.
const parentPayloadVariant = chain.forkChoice.getBlockHexAndBlockHash(parentBlockRootHex, parentBlockHashHex);
if (parentPayloadVariant === null || parentPayloadVariant.executionPayloadBlockHash === null) {
throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
code: ExecutionPayloadBidErrorCode.UNKNOWN_PARENT_BLOCK_HASH,
parentBlockHash: parentBlockHashHex,
});
}

// [IGNORE] `is_gas_limit_target_compatible(parent_gas_limit, bid.gas_limit, target_gas_limit)`,
// where `parent_gas_limit` is the `gas_limit` of the parent execution payload and
// `target_gas_limit` is `proposer_preferences.target_gas_limit`.
const bidGasLimit = Number(bid.gasLimit);
if (bidGasLimit !== proposerPreferences.message.targetGasLimit) {
throw new ExecutionPayloadBidError(GossipAction.REJECT, {
const parentGasLimit = parentPayloadVariant.executionPayloadGasLimit;
const targetGasLimit = proposerPreferences.message.targetGasLimit;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The targetGasLimit from proposerPreferences.message is likely a bigint (representing a uint64 in the spec). Since isGasLimitTargetCompatible expects a number, it is safer and more consistent with other parts of the codebase (e.g., produceBlockBody.ts) to explicitly convert it using Number().

Suggested change
const targetGasLimit = proposerPreferences.message.targetGasLimit;
const targetGasLimit = Number(proposerPreferences.message.targetGasLimit);

if (!isGasLimitTargetCompatible(parentGasLimit, bidGasLimit, targetGasLimit)) {
throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
code: ExecutionPayloadBidErrorCode.PROPOSER_PREFERENCES_GAS_LIMIT_MISMATCH,
builderIndex: bid.builderIndex,
bidGasLimit,
expectedGasLimit: proposerPreferences.message.targetGasLimit,
parentGasLimit,
targetGasLimit,
});
}

Expand Down Expand Up @@ -183,10 +203,6 @@ async function validateExecutionPayloadBid(
});
}

// [IGNORE] `bid.parent_block_hash` is the block hash of a known execution
// payload in fork choice.
// TODO GLOAS: implement this

// [REJECT] `signed_execution_payload_bid.signature` is valid with respect to the `bid.builder_index`.
const signatureSet = createSingleSignatureSetFromComponents(
PublicKey.fromBytes(builder.pubkey),
Expand All @@ -204,4 +220,6 @@ async function validateExecutionPayloadBid(

// Valid
chain.seenExecutionPayloadBids.add(bid.slot, bid.builderIndex);

return {proposerIndex: proposerPreferences.message.validatorIndex};
}
18 changes: 18 additions & 0 deletions packages/beacon-node/src/chain/validatorMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export type ValidatorMonitor = {
delaySec: Seconds,
envelope: gloas.SignedExecutionPayloadEnvelope
): void;
registerExecutionPayloadBid(src: OpSource, proposerIndex: ValidatorIndex, bid: gloas.ExecutionPayloadBid): void;
registerImportedBlock(block: BeaconBlock, data: {proposerBalanceDelta: number}): void;
onPoolSubmitUnaggregatedAttestation(
seenTimestampSec: number,
Expand Down Expand Up @@ -459,6 +460,23 @@ export function createValidatorMonitor(
// TODO GLOAS: implement execution payload envelope monitoring
},

registerExecutionPayloadBid(src, proposerIndex, bid) {
if (!validators.has(proposerIndex)) {
return;
}
log("Received an execution payload bid for monitored proposer", {
slot: bid.slot,
proposerIndex,
src,
builderIndex: bid.builderIndex,
gasLimit: bid.gasLimit,
value: bid.value.toString(),
parentBlockRoot: toRootHex(bid.parentBlockRoot),
parentBlockHash: toRootHex(bid.parentBlockHash),
blockHash: toRootHex(bid.blockHash),
});
},

registerImportedBlock(block, {proposerBalanceDelta}) {
const validator = validators.get(block.proposerIndex);
if (validator) {
Expand Down
5 changes: 1 addition & 4 deletions packages/beacon-node/src/execution/builder/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import {ChainForkConfig} from "@lodestar/config";
import {Logger} from "@lodestar/logger";
import {Metrics} from "../../metrics/metrics.js";
import {IExecutionBuilder} from "./interface.js";

export {getExpectedGasLimit} from "./utils.js";

import {ExecutionBuilderHttp, ExecutionBuilderHttpOpts, defaultExecutionBuilderHttpOpts} from "./http.js";
import {IExecutionBuilder} from "./interface.js";

export {ExecutionBuilderHttp, defaultExecutionBuilderHttpOpts};

Expand Down
19 changes: 0 additions & 19 deletions packages/beacon-node/src/execution/builder/utils.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -1215,7 +1215,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
}: GossipHandlerParamGeneric<GossipType.execution_payload_bid>) => {
const {serializedData} = gossipData;
const executionPayloadBid = sszDeserialize(topic, serializedData);
await validateGossipExecutionPayloadBid(chain, executionPayloadBid);
const {proposerIndex} = await validateGossipExecutionPayloadBid(chain, executionPayloadBid);

// Handle valid payload bid by storing in a bid pool
try {
Expand All @@ -1225,6 +1225,8 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
logger.error("Error adding to executionPayloadBid pool", {}, e as Error);
}

chain.validatorMonitor?.registerExecutionPayloadBid(OpSource.gossip, proposerIndex, executionPayloadBid.message);

chain.emitter.emit(routes.events.EventType.executionPayloadBid, {
version: config.getForkName(executionPayloadBid.message.slot),
data: executionPayloadBid,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ describe("sync / checkpoint sync optimistic flow for gloas", () => {
bn2Head.blockRoot,
nodeAHeadFull.executionPayloadBlockHash,
nodeAHeadFull.executionPayloadNumber,
nodeAHeadFull.executionPayloadGasLimit,
ExecutionStatus.Valid,
DataAvailabilityStatus.Available
);
Expand Down
2 changes: 2 additions & 0 deletions packages/beacon-node/test/spec/presets/fork_choice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ const forkChoiceTest =
const beaconBlockRoot = toHex(envelope.message.beaconBlockRoot);
const blockHash = toHex(envelope.message.payload.blockHash);
const blockNumber = envelope.message.payload.blockNumber;
const gasLimit = envelope.message.payload.gasLimit;

// Verify envelope against the state
const protoBlock = chain.forkChoice.getBlockHexDefaultStatus(beaconBlockRoot);
Expand Down Expand Up @@ -433,6 +434,7 @@ const forkChoiceTest =
beaconBlockRoot,
blockHash,
blockNumber,
gasLimit,
ExecutionStatus.Valid,
DataAvailabilityStatus.Available
);
Expand Down
Loading
Loading