Skip to content
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> {}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type IBeaconStateViewGloas,
computeEpochAtSlot,
computeTimeAtSlot,
getExpectedGasLimit,
isStatePostBellatrix,
isStatePostCapella,
isStatePostGloas,
Expand Down Expand Up @@ -53,13 +54,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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createSingleSignatureSetFromComponents,
getExecutionPayloadBidSigningRoot,
isActiveBuilder,
isGasLimitTargetCompatible,
isStatePostGloas,
} from "@lodestar/state-transition";
import {gloas} from "@lodestar/types";
Expand Down Expand Up @@ -128,14 +129,32 @@ 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. Partial implementation: we can only resolve the head's last committed bid because
// fork choice doesn't yet index per-payload metadata by EL block hash. Bids whose parent
// payload sits behind that (EMPTY-parent chains, sibling forks) get IGNORED and will
// re-enter the validator as the head catches up.
// TODO GLOAS: drop this guard once fork choice tracks executed-payload gas_limit by hash.
if (!byteArrayEquals(bid.parentBlockHash, state.latestExecutionPayloadBid.blockHash)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Check parent hash against executed payload, not latest bid

This guard uses state.latestExecutionPayloadBid.blockHash, but in Gloas that field can diverge from the latest executed EL payload hash after EMPTY-parent transitions (processParentExecutionPayload leaves state.latestBlockHash unchanged while processExecutionPayloadBid still updates latestExecutionPayloadBid). In that state, spec-valid bids with parent_block_hash == state.latestBlockHash are incorrectly IGNORED, and bids pointing to a non-executed bid hash can pass this check. This can cause the node to drop valid gossip bids and accept invalid parent references until head/state alignment changes.

Useful? React with 👍 / 👎.

throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
code: ExecutionPayloadBidErrorCode.UNKNOWN_PARENT_BLOCK_HASH,
parentBlockHash: parentBlockHashHex,
});
}

// [REJECT] `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) {
const parentGasLimit = Number(state.latestExecutionPayloadBid.gasLimit);
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.REJECT, {

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.

code: ExecutionPayloadBidErrorCode.PROPOSER_PREFERENCES_GAS_LIMIT_MISMATCH,
builderIndex: bid.builderIndex,
bidGasLimit,
expectedGasLimit: proposerPreferences.message.targetGasLimit,
parentGasLimit,
targetGasLimit,
});
}

Expand Down Expand Up @@ -183,10 +202,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 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.

28 changes: 28 additions & 0 deletions packages/state-transition/src/util/gloas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,34 @@ export function isActiveBuilder(builder: gloas.Builder, finalizedEpoch: Epoch):
return builder.depositEpoch < finalizedEpoch && builder.withdrawableEpoch === FAR_FUTURE_EPOCH;
}

/**
* Compute the gas limit that satisfies the EIP-1559 adjustment rule from `parentGasLimit`,
* clamping `targetGasLimit` into the allowed window of `±max(parentGasLimit / 1024, 1) - 1`.
*
* From https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1559.md
*/
export function getExpectedGasLimit(parentGasLimit: number, targetGasLimit: number): number {
const maxGasLimitDifference = Math.max(Math.floor(parentGasLimit / 1024), 1) - 1;

if (targetGasLimit > parentGasLimit) {
const gasDiff = targetGasLimit - parentGasLimit;
return parentGasLimit + Math.min(gasDiff, maxGasLimitDifference);
}

const gasDiff = parentGasLimit - targetGasLimit;
return parentGasLimit - Math.min(gasDiff, maxGasLimitDifference);
}

/**
* Check if `gasLimit` is compatible with `targetGasLimit` under the EIP-1559 transition rule
* from `parentGasLimit`. The bid must hit `targetGasLimit` when the target is within one
* adjustment step of the parent, otherwise it must hit the clamped boundary.
* Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.8/specs/gloas/builder.md#new-is_gas_limit_target_compatible
*/
export function isGasLimitTargetCompatible(parentGasLimit: number, gasLimit: number, targetGasLimit: number): boolean {
return gasLimit === getExpectedGasLimit(parentGasLimit, targetGasLimit);
}

/**
* Get the total pending balance to withdraw for a builder (from withdrawals + payments).
* Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/beacon-chain.md#new-get_pending_balance_to_withdraw_for_builder
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {describe, expect, it} from "vitest";
import {getExpectedGasLimit} from "../../../../src/execution/builder/utils.js";
import {getExpectedGasLimit} from "../../../src/util/gloas.js";

describe("execution / builder / utils", () => {
describe("util / gloas", () => {
describe("getExpectedGasLimit", () => {
const testCases: {
name: string;
Expand Down
4 changes: 3 additions & 1 deletion specrefs/functions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6645,7 +6645,9 @@
</spec>

- name: is_gas_limit_target_compatible#gloas
sources: []
sources:
- file: packages/state-transition/src/util/gloas.ts
search: export function isGasLimitTargetCompatible(
spec: |
<spec fn="is_gas_limit_target_compatible" fork="gloas" hash="3fa22023">
def is_gas_limit_target_compatible(
Expand Down
Loading