From 27a20754340c6fe83a3822f3ba219ac39c3c1efa Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Mon, 13 Apr 2026 16:21:51 +0100 Subject: [PATCH 01/39] feat: add cached PTC window to the state --- .../validation/payloadAttestationMessage.ts | 8 +- .../spec/presets/epoch_processing.test.ts | 4 + .../test/spec/presets/sanity.test.ts | 2 +- .../test/spec/utils/specTestIterator.ts | 9 +- .../state-transition/src/cache/epochCache.ts | 50 ++++--- packages/state-transition/src/epoch/index.ts | 9 ++ .../src/slot/upgradeStateToGloas.ts | 3 +- packages/state-transition/src/util/genesis.ts | 17 ++- packages/state-transition/src/util/gloas.ts | 75 ++++++++++- packages/types/src/gloas/sszTypes.ts | 17 ++- packages/types/src/gloas/types.ts | 2 + spec-tests-version.json | 2 +- specrefs/.ethspecify.yml | 20 ++- specrefs/containers.yml | 7 +- specrefs/functions.yml | 125 +++++++++++++++--- 15 files changed, 283 insertions(+), 67 deletions(-) diff --git a/packages/beacon-node/src/chain/validation/payloadAttestationMessage.ts b/packages/beacon-node/src/chain/validation/payloadAttestationMessage.ts index 2877c3041bd6..c44751b49074 100644 --- a/packages/beacon-node/src/chain/validation/payloadAttestationMessage.ts +++ b/packages/beacon-node/src/chain/validation/payloadAttestationMessage.ts @@ -18,7 +18,8 @@ export async function validateApiPayloadAttestationMessage( chain: IBeaconChain, payloadAttestationMessage: gloas.PayloadAttestationMessage ): Promise { - return validatePayloadAttestationMessage(chain, payloadAttestationMessage); + const prioritizeBls = true; + return validatePayloadAttestationMessage(chain, payloadAttestationMessage, prioritizeBls); } export async function validateGossipPayloadAttestationMessage( @@ -30,7 +31,8 @@ export async function validateGossipPayloadAttestationMessage( async function validatePayloadAttestationMessage( chain: IBeaconChain, - payloadAttestationMessage: gloas.PayloadAttestationMessage + payloadAttestationMessage: gloas.PayloadAttestationMessage, + prioritizeBls = false ): Promise { const {data, validatorIndex} = payloadAttestationMessage; const epoch = computeEpochAtSlot(data.slot); @@ -102,7 +104,7 @@ async function validatePayloadAttestationMessage( payloadAttestationMessage.signature ); - if (!(await chain.bls.verifySignatureSets([signatureSet]))) { + if (!(await chain.bls.verifySignatureSets([signatureSet], {batchable: true, priority: prioritizeBls}))) { throw new PayloadAttestationError(GossipAction.REJECT, { code: PayloadAttestationErrorCode.INVALID_SIGNATURE, }); diff --git a/packages/beacon-node/test/spec/presets/epoch_processing.test.ts b/packages/beacon-node/test/spec/presets/epoch_processing.test.ts index 67a5f226957d..ec90d3ff75a0 100644 --- a/packages/beacon-node/test/spec/presets/epoch_processing.test.ts +++ b/packages/beacon-node/test/spec/presets/epoch_processing.test.ts @@ -7,6 +7,7 @@ import { CachedBeaconStateAllForks, CachedBeaconStateAltair, CachedBeaconStateFulu, + CachedBeaconStateGloas, EpochTransitionCache, beforeProcessEpoch, } from "@lodestar/state-transition"; @@ -51,6 +52,9 @@ const epochTransitionFns: Record = { const fork = state.config.getForkSeq(state.slot); epochFns.processProposerLookahead(fork, state as CachedBeaconStateFulu, epochTransitionCache); }, + ptc_window: (state, epochTransitionCache) => { + epochFns.processPtcWindow(state as CachedBeaconStateGloas, epochTransitionCache); + }, builder_pending_payments: epochFns.processBuilderPendingPayments as EpochTransitionFn, }; diff --git a/packages/beacon-node/test/spec/presets/sanity.test.ts b/packages/beacon-node/test/spec/presets/sanity.test.ts index 1d35ab48ecf8..4e128e9b238b 100644 --- a/packages/beacon-node/test/spec/presets/sanity.test.ts +++ b/packages/beacon-node/test/spec/presets/sanity.test.ts @@ -46,7 +46,7 @@ const sanitySlots: TestRunnerFn = (for post: ssz[fork].BeaconState, }, shouldError: (testCase) => !testCase.post, - timeout: 30000, + timeout: 60000, getExpected: (testCase) => testCase.post, expectFunc: (_testCase, expected, actual) => { expectEqualBeaconState(fork, expected, actual); diff --git a/packages/beacon-node/test/spec/utils/specTestIterator.ts b/packages/beacon-node/test/spec/utils/specTestIterator.ts index 22c2e6748ec7..24d0b395942d 100644 --- a/packages/beacon-node/test/spec/utils/specTestIterator.ts +++ b/packages/beacon-node/test/spec/utils/specTestIterator.ts @@ -74,12 +74,11 @@ export const defaultSkipOpts: SkipOpts = { /^.+\/light_client\/data_collection\/.*/, /^gloas\/fork_choice\/.*$/, /^gloas\/ssz_static\/ForkChoiceNode.*$/, + // Ignore the partial data column container additions for now; they are outside the alpha.4/#4979 runtime scope. + /^fulu\/ssz_static\/PartialDataColumn(Header|PartsMetadata|Sidecar)\/.*$/, + /^gloas\/ssz_static\/PartialDataColumn(Header|PartsMetadata|Sidecar)\/.*$/, ], - skippedTests: [ - // TODO GLOAS: broken in v1.7.0-alpha.3 due to missing voluntary_exit.ssz_snappy input file - // Fixed by https://github.com/ethereum/consensus-specs/pull/5005 in v1.7.0-alpha.4 - /^gloas\/operations\/voluntary_exit\/pyspec_tests\/builder_voluntary_exit__success$/, - ], + skippedTests: [], skippedRunners: [], }; diff --git a/packages/state-transition/src/cache/epochCache.ts b/packages/state-transition/src/cache/epochCache.ts index d87c998d1e8a..4eefdff58eb2 100644 --- a/packages/state-transition/src/cache/epochCache.ts +++ b/packages/state-transition/src/cache/epochCache.ts @@ -32,10 +32,10 @@ import { calculateShufflingDecisionRoot, computeEpochShuffling, } from "../util/epochShuffling.js"; +import {getPtcWindowEpochCacheData} from "../util/gloas.js"; import { computeActivationExitEpoch, computeEpochAtSlot, - computePayloadTimelinessCommitteesForEpoch, computeProposers, computeSyncPeriodAtEpoch, getActivationChurnLimit, @@ -56,7 +56,7 @@ import {sumTargetUnslashedBalanceIncrements} from "../util/targetUnslashedBalanc import {EffectiveBalanceIncrements, getEffectiveBalanceIncrementsWithLen} from "./effectiveBalanceIncrements.js"; import {EpochTransitionCache} from "./epochTransitionCache.js"; import {PubkeyCache, createPubkeyCache, syncPubkeys} from "./pubkeyCache.js"; -import {CachedBeaconStateAllForks, CachedBeaconStateFulu} from "./stateCache.js"; +import {CachedBeaconStateAllForks, CachedBeaconStateFulu, CachedBeaconStateGloas} from "./stateCache.js"; import { SyncCommitteeCache, SyncCommitteeCacheEmpty, @@ -226,11 +226,12 @@ export class EpochCache { /** TODO: Indexed SyncCommitteeCache */ nextSyncCommitteeIndexed: SyncCommitteeCache; - // TODO GLOAS: See if we need to cache PTC for next epoch // PTC for previous epoch, required for slot N block validating slot N-1 attestations previousPayloadTimelinessCommittees: Uint32Array[]; // PTC for current epoch, computed eagerly at epoch transition payloadTimelinessCommittees: Uint32Array[]; + // PTC for next epoch, precomputed from the ptc window for future duty serving + nextPayloadTimelinessCommittees: Uint32Array[]; // TODO: Helper stats syncPeriod: SyncPeriod; @@ -270,6 +271,7 @@ export class EpochCache { nextSyncCommitteeIndexed: SyncCommitteeCache; previousPayloadTimelinessCommittees: Uint32Array[]; payloadTimelinessCommittees: Uint32Array[]; + nextPayloadTimelinessCommittees: Uint32Array[]; epoch: Epoch; syncPeriod: SyncPeriod; }) { @@ -301,6 +303,7 @@ export class EpochCache { this.nextSyncCommitteeIndexed = data.nextSyncCommitteeIndexed; this.previousPayloadTimelinessCommittees = data.previousPayloadTimelinessCommittees; this.payloadTimelinessCommittees = data.payloadTimelinessCommittees; + this.nextPayloadTimelinessCommittees = data.nextPayloadTimelinessCommittees; this.epoch = data.epoch; this.syncPeriod = data.syncPeriod; } @@ -451,25 +454,13 @@ export class EpochCache { nextSyncCommitteeIndexed = new SyncCommitteeCacheEmpty(); } - // Compute PTC for all slots in the prev/current epoch + // Copy previous/current epoch PTC slices from state.ptcWindow once, then serve hot-path lookups from epochCtx. let previousPayloadTimelinessCommittees: Uint32Array[] = []; let payloadTimelinessCommittees: Uint32Array[] = []; + let nextPayloadTimelinessCommittees: Uint32Array[] = []; if (currentEpoch >= config.GLOAS_FORK_EPOCH) { - payloadTimelinessCommittees = computePayloadTimelinessCommitteesForEpoch( - state, - currentEpoch, - currentShuffling.committees, - effectiveBalanceIncrements - ); - - if (!isGenesis && previousEpoch >= config.GLOAS_FORK_EPOCH) { - previousPayloadTimelinessCommittees = computePayloadTimelinessCommitteesForEpoch( - state, - previousEpoch, - previousShuffling.committees, - effectiveBalanceIncrements - ); - } + ({previousPayloadTimelinessCommittees, payloadTimelinessCommittees, nextPayloadTimelinessCommittees} = + getPtcWindowEpochCacheData(state as CachedBeaconStateGloas)); } // Precompute churnLimit for efficient initiateValidatorExit() during block proposing MUST be recompute everytime the @@ -546,6 +537,7 @@ export class EpochCache { nextSyncCommitteeIndexed, previousPayloadTimelinessCommittees, payloadTimelinessCommittees, + nextPayloadTimelinessCommittees, epoch: currentEpoch, syncPeriod: computeSyncPeriodAtEpoch(currentEpoch), }); @@ -592,6 +584,7 @@ export class EpochCache { nextSyncCommitteeIndexed: this.nextSyncCommitteeIndexed, previousPayloadTimelinessCommittees: this.previousPayloadTimelinessCommittees, payloadTimelinessCommittees: this.payloadTimelinessCommittees, + nextPayloadTimelinessCommittees: this.nextPayloadTimelinessCommittees, epoch: this.epoch, syncPeriod: this.syncPeriod, }); @@ -702,14 +695,13 @@ export class EpochCache { this.proposersPrevEpoch = this.proposers; if (upcomingEpoch >= this.config.GLOAS_FORK_EPOCH) { - // Shift and compute current epoch PTC eagerly for all slots - this.previousPayloadTimelinessCommittees = this.payloadTimelinessCommittees; - this.payloadTimelinessCommittees = computePayloadTimelinessCommitteesForEpoch( - state, - upcomingEpoch, - this.currentShuffling.committees, - this.effectiveBalanceIncrements - ); + // processPtcWindow() already rotated the canonical state field earlier in process_epoch. + // Copy the previous/current epoch slices into epochCtx so validation stays on cached arrays. + ({ + previousPayloadTimelinessCommittees: this.previousPayloadTimelinessCommittees, + payloadTimelinessCommittees: this.payloadTimelinessCommittees, + nextPayloadTimelinessCommittees: this.nextPayloadTimelinessCommittees, + } = getPtcWindowEpochCacheData(state as CachedBeaconStateGloas)); } if (upcomingEpoch >= this.config.FULU_FORK_EPOCH) { // Populate proposer cache with lookahead from state @@ -1042,6 +1034,10 @@ export class EpochCache { return this.previousPayloadTimelinessCommittees[slot % SLOTS_PER_EPOCH]; } + if (epoch === this.epoch + 1 && this.nextPayloadTimelinessCommittees.length > 0) { + return this.nextPayloadTimelinessCommittees[slot % SLOTS_PER_EPOCH]; + } + throw new Error(`Payload Timeliness Committee is not available for slot=${slot}`); } diff --git a/packages/state-transition/src/epoch/index.ts b/packages/state-transition/src/epoch/index.ts index 47aa812ef792..1792b52d5587 100644 --- a/packages/state-transition/src/epoch/index.ts +++ b/packages/state-transition/src/epoch/index.ts @@ -16,6 +16,7 @@ import { CachedBeaconStatePhase0, EpochTransitionCache, } from "../types.js"; +import {processPtcWindow} from "../util/gloas.js"; import {processBuilderPendingPayments} from "./processBuilderPendingPayments.js"; import {processEffectiveBalanceUpdates} from "./processEffectiveBalanceUpdates.js"; import {processEth1DataReset} from "./processEth1DataReset.js"; @@ -55,6 +56,7 @@ export { processPendingDeposits, processPendingConsolidations, processProposerLookahead, + processPtcWindow, processBuilderPendingPayments, }; @@ -81,6 +83,7 @@ export enum EpochTransitionStep { processPendingDeposits = "processPendingDeposits", processPendingConsolidations = "processPendingConsolidations", processProposerLookahead = "processProposerLookahead", + processPtcWindow = "processPtcWindow", processBuilderPendingPayments = "processBuilderPendingPayments", } @@ -211,4 +214,10 @@ export function processEpoch( processProposerLookahead(fork, state as CachedBeaconStateFulu, cache); timer?.(); } + + if (fork >= ForkSeq.gloas) { + const timer = metrics?.epochTransitionStepTime.startTimer({step: EpochTransitionStep.processPtcWindow}); + processPtcWindow(state as CachedBeaconStateGloas, cache); + timer?.(); + } } diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index 64403823fa8e..a269dba199c1 100644 --- a/packages/state-transition/src/slot/upgradeStateToGloas.ts +++ b/packages/state-transition/src/slot/upgradeStateToGloas.ts @@ -5,7 +5,7 @@ import {isValidDepositSignature} from "../block/processDeposit.js"; import {applyDepositForBuilder} from "../block/processDepositRequest.js"; import {getCachedBeaconState} from "../cache/stateCache.js"; import {CachedBeaconStateFulu, CachedBeaconStateGloas} from "../types.js"; -import {isBuilderWithdrawalCredential} from "../util/gloas.js"; +import {initializePtcWindow, isBuilderWithdrawalCredential} from "../util/gloas.js"; import {isValidatorKnown} from "../util/index.js"; /** @@ -61,6 +61,7 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea stateGloasView.pendingPartialWithdrawals = stateGloasCloned.pendingPartialWithdrawals; stateGloasView.pendingConsolidations = stateGloasCloned.pendingConsolidations; stateGloasView.proposerLookahead = stateGloasCloned.proposerLookahead; + stateGloasView.ptcWindow = ssz.gloas.PtcWindow.toViewDU(initializePtcWindow(stateFulu)); for (let i = 0; i < SLOTS_PER_HISTORICAL_ROOT; i++) { stateGloasView.executionPayloadAvailability.set(i, true); diff --git a/packages/state-transition/src/util/genesis.ts b/packages/state-transition/src/util/genesis.ts index 8ee6b9fda3a2..dd37d6ccf644 100644 --- a/packages/state-transition/src/util/genesis.ts +++ b/packages/state-transition/src/util/genesis.ts @@ -15,10 +15,17 @@ import {processDeposit} from "../block/processDeposit.js"; import {EpochCacheImmutableData} from "../cache/epochCache.js"; import {createCachedBeaconState} from "../cache/stateCache.js"; import {increaseBalance} from "../index.js"; -import {BeaconStateAllForks, CachedBeaconStateAllForks, CachedBeaconStateElectra} from "../types.js"; +import { + BeaconStateAllForks, + CachedBeaconStateAllForks, + CachedBeaconStateElectra, + CachedBeaconStateFulu, +} from "../types.js"; import {newFilledArray} from "./array.js"; import {getTemporaryBlockHeader} from "./blockRoot.js"; import {computeEpochAtSlot} from "./epoch.js"; +import {initializeProposerLookahead} from "./fulu.js"; +import {initializePtcWindow} from "./gloas.js"; import {getNextSyncCommittee} from "./syncCommittee.js"; import {getActiveValidatorIndices, getMaxEffectiveBalance} from "./validator.js"; @@ -326,15 +333,23 @@ export function initializeBeaconStateFromEth1( stateFulu.latestExecutionPayloadHeader = (executionPayloadHeader as CompositeViewDU) ?? ssz.fulu.ExecutionPayloadHeader.defaultViewDU(); + stateFulu.proposerLookahead = ssz.fulu.ProposerLookahead.toViewDU( + initializeProposerLookahead(state as CachedBeaconStateElectra) + ); } if (fork >= ForkSeq.gloas) { const stateGloas = state as CompositeViewDU; stateGloas.fork.previousVersion = config.GLOAS_FORK_VERSION; stateGloas.fork.currentVersion = config.GLOAS_FORK_VERSION; + stateGloas.ptcWindow = ssz.gloas.PtcWindow.toViewDU(initializePtcWindow(state as CachedBeaconStateFulu)); } state.commit(); + if (fork >= ForkSeq.fulu) { + return createCachedBeaconState(state as BeaconStateAllForks, immutableData, {skipSyncCommitteeCache: true}); + } + return state; } diff --git a/packages/state-transition/src/util/gloas.ts b/packages/state-transition/src/util/gloas.ts index ae4e0fec4e6f..141ed0fcea96 100644 --- a/packages/state-transition/src/util/gloas.ts +++ b/packages/state-transition/src/util/gloas.ts @@ -6,16 +6,22 @@ import { EFFECTIVE_BALANCE_INCREMENT, FAR_FUTURE_EPOCH, MIN_DEPOSIT_AMOUNT, + MIN_SEED_LOOKAHEAD, + PTC_SIZE, SLOTS_PER_EPOCH, } from "@lodestar/params"; -import {BuilderIndex, Epoch, ValidatorIndex, gloas} from "@lodestar/types"; +import {BuilderIndex, Epoch, ValidatorIndex, gloas, ssz} from "@lodestar/types"; import {AttestationData} from "@lodestar/types/phase0"; import {byteArrayEquals} from "@lodestar/utils"; -import {IBeaconStateViewGloas} from "../stateView/interface.js"; -import {CachedBeaconStateGloas} from "../types.js"; +import type {EpochTransitionCache} from "../cache/epochTransitionCache.js"; +import type {CachedBeaconStateFulu, CachedBeaconStateGloas} from "../cache/stateCache.js"; +import type {IBeaconStateViewGloas} from "../stateView/interface.js"; import {getBlockRootAtSlot} from "./blockRoot.js"; import {computeEpochAtSlot} from "./epoch.js"; +import {computeEpochShuffling} from "./epochShuffling.js"; import {RootCache} from "./rootCache.js"; +import {computePayloadTimelinessCommitteesForEpoch} from "./seed.js"; +import {getActiveValidatorIndices} from "./validator.js"; export function isBuilderWithdrawalCredential(withdrawalCredentials: Uint8Array): boolean { return withdrawalCredentials[0] === BUILDER_WITHDRAWAL_PREFIX; @@ -171,3 +177,66 @@ export function isAttestationSameSlotRootCache(rootCache: RootCache, data: Attes export function isParentBlockFull(state: CachedBeaconStateGloas | IBeaconStateViewGloas): boolean { return byteArrayEquals(state.latestExecutionPayloadBid.blockHash, state.latestBlockHash); } + +export function initializePtcWindow(state: CachedBeaconStateFulu): number[][] { + const ptcWindow = Array.from({length: SLOTS_PER_EPOCH}, () => Array.from(new Uint32Array(PTC_SIZE))); + const currentEpoch = state.epochCtx.epoch; + + for (let epochOffset = 0; epochOffset <= MIN_SEED_LOOKAHEAD; epochOffset++) { + const epoch = currentEpoch + epochOffset; + const shuffling = + state.epochCtx.getShufflingAtEpochOrNull(epoch) ?? + computeEpochShuffling(state, getActiveValidatorIndices(state, epoch), epoch); + + ptcWindow.push( + ...computePayloadTimelinessCommitteesForEpoch( + state, + epoch, + shuffling.committees, + state.epochCtx.effectiveBalanceIncrements + ).map((committee) => Array.from(committee)) + ); + } + + return ptcWindow; +} + +export function processPtcWindow(state: CachedBeaconStateGloas, cache: EpochTransitionCache): void { + const nextEpoch = state.epochCtx.epoch + MIN_SEED_LOOKAHEAD + 1; + const nextShuffling = + cache.nextShuffling ?? computeEpochShuffling(state, cache.nextShufflingActiveIndices, nextEpoch); + cache.nextShuffling = nextShuffling; + + const remainingPtcWindow = state.ptcWindow.toValue().slice(SLOTS_PER_EPOCH); + const nextEpochPtcs = computePayloadTimelinessCommitteesForEpoch( + state, + nextEpoch, + nextShuffling.committees, + state.epochCtx.effectiveBalanceIncrements + ); + + state.ptcWindow = ssz.gloas.PtcWindow.toViewDU([ + ...remainingPtcWindow, + ...nextEpochPtcs.map((committee) => Array.from(committee)), + ]); +} + +export function getPtcWindowEpochCacheData(state: CachedBeaconStateGloas): { + previousPayloadTimelinessCommittees: Uint32Array[]; + payloadTimelinessCommittees: Uint32Array[]; + nextPayloadTimelinessCommittees: Uint32Array[]; +} { + const ptcWindow = state.ptcWindow.toValue(); + + return { + previousPayloadTimelinessCommittees: ptcWindow + .slice(0, SLOTS_PER_EPOCH) + .map((committee: ValidatorIndex[]) => Uint32Array.from(committee)), + payloadTimelinessCommittees: ptcWindow + .slice(SLOTS_PER_EPOCH, 2 * SLOTS_PER_EPOCH) + .map((committee: ValidatorIndex[]) => Uint32Array.from(committee)), + nextPayloadTimelinessCommittees: ptcWindow + .slice(2 * SLOTS_PER_EPOCH, 3 * SLOTS_PER_EPOCH) + .map((committee: ValidatorIndex[]) => Uint32Array.from(committee)), + }; +} diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index b9bf89ad583f..47e142b4e415 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -1,9 +1,17 @@ -import {BitVectorType, ContainerType, ListBasicType, ListCompositeType, VectorCompositeType} from "@chainsafe/ssz"; +import { + BitVectorType, + ContainerType, + ListBasicType, + ListCompositeType, + VectorBasicType, + VectorCompositeType, +} from "@chainsafe/ssz"; import { BUILDER_PENDING_WITHDRAWALS_LIMIT, BUILDER_REGISTRY_LIMIT, HISTORICAL_ROOTS_LIMIT, MAX_PAYLOAD_ATTESTATIONS, + MIN_SEED_LOOKAHEAD, NUMBER_OF_COLUMNS, PTC_SIZE, SLOTS_PER_EPOCH, @@ -66,6 +74,12 @@ export const BuilderPendingPayment = new ContainerType( {typeName: "BuilderPendingPayment", jsonCase: "eth2"} ); +export const PayloadTimelinessCommittee = new VectorBasicType(ValidatorIndex, PTC_SIZE); +export const PtcWindow = new VectorCompositeType( + PayloadTimelinessCommittee, + (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH +); + export const PayloadAttestationData = new ContainerType( { beaconBlockRoot: Root, @@ -263,6 +277,7 @@ export const BeaconState = new ContainerType( builderPendingWithdrawals: new ListCompositeType(BuilderPendingWithdrawal, BUILDER_PENDING_WITHDRAWALS_LIMIT), // New in GLOAS:EIP7732 latestBlockHash: Bytes32, // New in GLOAS:EIP7732 payloadExpectedWithdrawals: capellaSsz.Withdrawals, // New in GLOAS:EIP7732 + ptcWindow: PtcWindow, // New in GLOAS:EIP7732 }, {typeName: "BeaconState", jsonCase: "eth2"} ); diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index f90e74bbf416..ecd506932544 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -4,6 +4,8 @@ import * as ssz from "./sszTypes.js"; export type Builder = ValueOf; export type BuilderPendingWithdrawal = ValueOf; export type BuilderPendingPayment = ValueOf; +export type PayloadTimelinessCommittee = ValueOf; +export type PtcWindow = ValueOf; export type PayloadAttestationData = ValueOf; export type PayloadAttestation = ValueOf; export type PayloadAttestationMessage = ValueOf; diff --git a/spec-tests-version.json b/spec-tests-version.json index fd897522492f..8e29f2192c02 100644 --- a/spec-tests-version.json +++ b/spec-tests-version.json @@ -1,6 +1,6 @@ { "ethereumConsensusSpecsTests": { - "specVersion": "v1.7.0-alpha.3", + "specVersion": "v1.7.0-alpha.4", "specTestsRepoUrl": "https://github.com/ethereum/consensus-specs", "outputDirBase": "spec-tests", "testsToDownload": [ diff --git a/specrefs/.ethspecify.yml b/specrefs/.ethspecify.yml index 2c1f0d75b862..41b05a145e51 100644 --- a/specrefs/.ethspecify.yml +++ b/specrefs/.ethspecify.yml @@ -1,4 +1,4 @@ -version: v1.7.0-alpha.3 +version: v1.7.0-alpha.4 style: full specrefs: @@ -54,6 +54,12 @@ exceptions: containers: # gloas - ForkChoiceNode#gloas + - PartialDataColumnHeader#gloas + + # fulu (not implemented yet) + - PartialDataColumnHeader#fulu + - PartialDataColumnPartsMetadata#fulu + - PartialDataColumnSidecar#fulu # heze (not implemented) - BeaconState#heze @@ -65,6 +71,7 @@ exceptions: dataclasses: # phase0 - LatestMessage#phase0 + - Seen#phase0 # bellatrix - OptimisticStore#bellatrix @@ -93,6 +100,15 @@ exceptions: functions: # phase0 - bytes_to_uint64#phase0 + - compute_time_at_slot_ms#phase0 + - is_not_from_future_slot#phase0 + - is_within_slot_range#phase0 + - validate_attester_slashing_gossip#phase0 + - validate_beacon_aggregate_and_proof_gossip#phase0 + - validate_beacon_attestation_gossip#phase0 + - validate_beacon_block_gossip#phase0 + - validate_proposer_slashing_gossip#phase0 + - validate_voluntary_exit_gossip#phase0 - compute_fork_version#phase0 - compute_proposer_score#phase0 - get_aggregate_signature#phase0 @@ -259,6 +275,8 @@ exceptions: - vanishing_polynomialcoeff#fulu - verify_cell_kzg_proof_batch#fulu - verify_cell_kzg_proof_batch_impl#fulu + - verify_partial_data_column_header_inclusion_proof#fulu + - verify_partial_data_column_sidecar_kzg_proofs#fulu # gloas - add_builder_to_registry#gloas diff --git a/specrefs/containers.yml b/specrefs/containers.yml index 3f7ce8b6f803..f4008da2944f 100644 --- a/specrefs/containers.yml +++ b/specrefs/containers.yml @@ -572,7 +572,7 @@ - file: packages/types/src/gloas/sszTypes.ts search: export const BeaconState = spec: | - + class BeaconState(Container): genesis_time: uint64 genesis_validators_root: Root @@ -629,12 +629,14 @@ latest_block_hash: Hash32 # [New in Gloas:EIP7732] payload_expected_withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] + # [New in Gloas:EIP7732] + ptc_window: Vector[Vector[ValidatorIndex, PTC_SIZE], (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH] - name: BeaconState#heze sources: [] spec: | - + class BeaconState(Container): genesis_time: uint64 genesis_validators_root: Root @@ -682,6 +684,7 @@ builder_pending_withdrawals: List[BuilderPendingWithdrawal, BUILDER_PENDING_WITHDRAWALS_LIMIT] latest_block_hash: Hash32 payload_expected_withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] + ptc_window: Vector[Vector[ValidatorIndex, PTC_SIZE], (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH] - name: BlobIdentifier#deneb diff --git a/specrefs/functions.yml b/specrefs/functions.yml index e0e194c8702a..6784ce1f86fe 100644 --- a/specrefs/functions.yml +++ b/specrefs/functions.yml @@ -1100,6 +1100,29 @@ return (committee_weight * PROPOSER_SCORE_BOOST) // 100 +- name: compute_ptc#gloas + sources: + - file: packages/state-transition/src/util/seed.ts + search: export function computePayloadTimelinessCommitteeForSlot( + spec: | + + def compute_ptc(state: BeaconState, slot: Slot) -> Vector[ValidatorIndex, PTC_SIZE]: + """ + Get the payload timeliness committee for the given ``slot``. + """ + epoch = compute_epoch_at_slot(slot) + seed = hash(get_seed(state, epoch, DOMAIN_PTC_ATTESTER) + uint_to_bytes(slot)) + indices: List[ValidatorIndex] = [] + # Concatenate all committees for this slot in order + committees_per_slot = get_committee_count_per_slot(state, epoch) + for i in range(committees_per_slot): + committee = get_beacon_committee(state, slot, CommitteeIndex(i)) + indices.extend(committee) + return compute_balance_weighted_selection( + state, indices, seed, size=PTC_SIZE, shuffle_indices=False + ) + + - name: compute_pulled_up_tip#phase0 sources: - file: packages/fork-choice/src/forkChoice/forkChoice.ts @@ -4330,28 +4353,25 @@ search: '^\s+getPayloadTimelinessCommittee\(' regex: true spec: | - + def get_ptc(state: BeaconState, slot: Slot) -> Vector[ValidatorIndex, PTC_SIZE]: """ Get the payload timeliness committee for the given ``slot``. """ epoch = compute_epoch_at_slot(slot) - seed = hash(get_seed(state, epoch, DOMAIN_PTC_ATTESTER) + uint_to_bytes(slot)) - indices: List[ValidatorIndex] = [] - # Concatenate all committees for this slot in order - committees_per_slot = get_committee_count_per_slot(state, epoch) - for i in range(committees_per_slot): - committee = get_beacon_committee(state, slot, CommitteeIndex(i)) - indices.extend(committee) - return compute_balance_weighted_selection( - state, indices, seed, size=PTC_SIZE, shuffle_indices=False - ) + state_epoch = get_current_epoch(state) + if epoch < state_epoch: + assert epoch + 1 == state_epoch + return state.ptc_window[slot % SLOTS_PER_EPOCH] + assert epoch <= state_epoch + MIN_SEED_LOOKAHEAD + offset = (epoch - state_epoch + 1) * SLOTS_PER_EPOCH + return state.ptc_window[offset + slot % SLOTS_PER_EPOCH] - name: get_ptc_assignment#gloas sources: [] spec: | - + def get_ptc_assignment( state: BeaconState, epoch: Epoch, validator_index: ValidatorIndex ) -> Optional[Slot]: @@ -4360,8 +4380,8 @@ index ``validator_index`` is a member of the PTC. Returns None if no assignment is found. """ - next_epoch = Epoch(get_current_epoch(state) + 1) - assert epoch <= next_epoch + max_epoch = Epoch(get_current_epoch(state) + MIN_SEED_LOOKAHEAD) + assert epoch <= max_epoch start_slot = compute_start_slot_at_epoch(epoch) for slot in range(start_slot, start_slot + SLOTS_PER_EPOCH): @@ -5138,6 +5158,34 @@ return lookahead +- name: initialize_ptc_window#gloas + sources: + - file: packages/state-transition/src/util/gloas.ts + search: export function initializePtcWindow( + spec: | + + def initialize_ptc_window( + state: BeaconState, + ) -> Vector[Vector[ValidatorIndex, PTC_SIZE], (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH]: + """ + Return the cached PTC window starting from the current epoch. + Used to initialize the ``ptc_window`` field in the beacon state at genesis and after forks. + """ + empty_previous_epoch = [ + Vector[ValidatorIndex, PTC_SIZE]([ValidatorIndex(0) for _ in range(PTC_SIZE)]) + for _ in range(SLOTS_PER_EPOCH) + ] + + ptcs = [] + current_epoch = get_current_epoch(state) + for e in range(1 + MIN_SEED_LOOKAHEAD): + epoch = Epoch(current_epoch + e) + start_slot = compute_start_slot_at_epoch(epoch) + ptcs += [compute_ptc(state, Slot(start_slot + i)) for i in range(SLOTS_PER_EPOCH)] + + return empty_previous_epoch + ptcs + + - name: initiate_builder_exit#gloas sources: - file: packages/state-transition/src/util/gloas.ts @@ -6275,12 +6323,21 @@ - name: is_valid_proposal_slot#gloas sources: [] spec: | - + def is_valid_proposal_slot(state: BeaconState, preferences: ProposerPreferences) -> bool: """ - Check if the validator is the proposer for the given slot in the next epoch. + Check if the validator is the proposer for the given slot in the current or + next epoch. """ - index = SLOTS_PER_EPOCH + preferences.proposal_slot % SLOTS_PER_EPOCH + current_epoch = get_current_epoch(state) + proposal_epoch = compute_epoch_at_slot(preferences.proposal_slot) + if proposal_epoch < current_epoch: + return False + if proposal_epoch > current_epoch + Epoch(1): + return False + + index = (proposal_epoch - current_epoch) * SLOTS_PER_EPOCH + index += preferences.proposal_slot % SLOTS_PER_EPOCH return state.proposer_lookahead[index] == preferences.validator_index @@ -6931,7 +6988,7 @@ - name: on_payload_attestation_message#gloas sources: [] spec: | - + def on_payload_attestation_message( store: Store, ptc_message: PayloadAttestationMessage, is_from_block: bool = False ) -> None: @@ -6942,6 +6999,7 @@ # The beacon block root must be known data = ptc_message.data # PTC attestation must be for a known block. If block is unknown, delay consideration until the block is found + assert data.beacon_block_root in store.block_states state = store.block_states[data.beacon_block_root] ptc = get_ptc(state, data.slot) # PTC votes can only change the vote for their assigned beacon block, return early otherwise @@ -8139,7 +8197,7 @@ - name: process_epoch#gloas sources: [] spec: | - + def process_epoch(state: BeaconState) -> None: process_justification_and_finalization(state) process_inactivity_updates(state) @@ -8158,6 +8216,8 @@ process_participation_flag_updates(state) process_sync_committee_updates(state) process_proposer_lookahead(state) + # [New in Gloas:EIP7732] + process_ptc_window(state) - name: process_eth1_data#phase0 @@ -9210,6 +9270,26 @@ slash_validator(state, header_1.proposer_index) +- name: process_ptc_window#gloas + sources: + - file: packages/state-transition/src/util/gloas.ts + search: export function processPtcWindow( + spec: | + + def process_ptc_window(state: BeaconState) -> None: + """ + Update the cached PTC window. + """ + # Shift all epochs forward by one + state.ptc_window[: len(state.ptc_window) - SLOTS_PER_EPOCH] = state.ptc_window[SLOTS_PER_EPOCH:] + # Fill in the last epoch + next_epoch = Epoch(get_current_epoch(state) + MIN_SEED_LOOKAHEAD + 1) + start_slot = compute_start_slot_at_epoch(next_epoch) + state.ptc_window[len(state.ptc_window) - SLOTS_PER_EPOCH :] = [ + compute_ptc(state, Slot(slot)) for slot in range(start_slot, start_slot + SLOTS_PER_EPOCH) + ] + + - name: process_randao#phase0 sources: - file: packages/state-transition/src/block/processRandao.ts @@ -11377,7 +11457,7 @@ - file: packages/state-transition/src/slot/upgradeStateToGloas.ts search: export function upgradeStateToGloas( spec: | - + def upgrade_to_gloas(pre: fulu.BeaconState) -> BeaconState: epoch = fulu.get_current_epoch(pre) @@ -11444,6 +11524,8 @@ latest_block_hash=pre.latest_execution_payload_header.block_hash, # [New in Gloas:EIP7732] payload_expected_withdrawals=[], + # [New in Gloas:EIP7732] + ptc_window=initialize_ptc_window(pre), ) # [New in Gloas:EIP7732] @@ -11455,7 +11537,7 @@ - name: upgrade_to_heze#heze sources: [] spec: | - + def upgrade_to_heze(pre: gloas.BeaconState) -> BeaconState: epoch = gloas.get_current_epoch(pre) latest_execution_payload_bid = ExecutionPayloadBid( @@ -11526,6 +11608,7 @@ builder_pending_withdrawals=pre.builder_pending_withdrawals, latest_block_hash=pre.latest_block_hash, payload_expected_withdrawals=pre.payload_expected_withdrawals, + ptc_window=pre.ptc_window, ) return post From 613aa400bd1f26dce6ef00eadfc056b9c664e2c5 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:05:47 -0700 Subject: [PATCH 02/39] Update specref --- specrefs/functions.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/specrefs/functions.yml b/specrefs/functions.yml index 6784ce1f86fe..13331bcb0d92 100644 --- a/specrefs/functions.yml +++ b/specrefs/functions.yml @@ -4679,18 +4679,23 @@ - name: get_upcoming_proposal_slots#gloas sources: [] spec: | - + def get_upcoming_proposal_slots( state: BeaconState, validator_index: ValidatorIndex ) -> Sequence[Slot]: """ - Get the slots in the next epoch for which ``validator_index`` is proposing. + Get the future slots in the current epoch and the slots in the next + epoch for which ``validator_index`` is proposing. """ - return [ - Slot(compute_start_slot_at_epoch(get_current_epoch(state) + Epoch(1)) + offset) - for offset, proposer_index in enumerate(state.proposer_lookahead[SLOTS_PER_EPOCH:]) - if validator_index == proposer_index - ] + current_epoch_start_slot = compute_start_slot_at_epoch(get_current_epoch(state)) + upcoming_proposal_slots = [] + for offset, proposer_index in enumerate(state.proposer_lookahead): + slot = Slot(current_epoch_start_slot + offset) + if slot <= state.slot: + continue + if validator_index == proposer_index: + upcoming_proposal_slots.append(slot) + return upcoming_proposal_slots - name: get_validator_activation_churn_limit#deneb From f34a278a7e467edbbb0b33c91b9f5b56a3e34950 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:46:00 -0700 Subject: [PATCH 03/39] feat: add EIP-7928 BlockAccessList to gloas ExecutionPayload Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/types/src/gloas/sszTypes.ts | 15 +++++++++++++-- packages/types/src/gloas/types.ts | 2 ++ packages/types/src/types.ts | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index b9bf89ad583f..b686c73426a6 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -1,8 +1,9 @@ -import {BitVectorType, ContainerType, ListBasicType, ListCompositeType, VectorCompositeType} from "@chainsafe/ssz"; +import {BitVectorType, ByteListType, ContainerType, ListBasicType, ListCompositeType, VectorCompositeType} from "@chainsafe/ssz"; import { BUILDER_PENDING_WITHDRAWALS_LIMIT, BUILDER_REGISTRY_LIMIT, HISTORICAL_ROOTS_LIMIT, + MAX_BYTES_PER_TRANSACTION, MAX_PAYLOAD_ATTESTATIONS, NUMBER_OF_COLUMNS, PTC_SIZE, @@ -146,9 +147,19 @@ export const SignedExecutionPayloadBid = new ContainerType( {typeName: "SignedExecutionPayloadBid", jsonCase: "eth2"} ); +export const BlockAccessList = new ByteListType(MAX_BYTES_PER_TRANSACTION); + +export const ExecutionPayload = new ContainerType( + { + ...electraSsz.ExecutionPayload.fields, + blockAccessList: BlockAccessList, // New in GLOAS:EIP-7928 + }, + {typeName: "ExecutionPayload", jsonCase: "eth2"} +); + export const ExecutionPayloadEnvelope = new ContainerType( { - payload: electraSsz.ExecutionPayload, + payload: ExecutionPayload, executionRequests: electraSsz.ExecutionRequests, builderIndex: BuilderIndex, beaconBlockRoot: Root, diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index f90e74bbf416..891f9eae22dc 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -12,6 +12,8 @@ export type ProposerPreferences = ValueOf; export type SignedProposerPreferences = ValueOf; export type ExecutionPayloadBid = ValueOf; export type SignedExecutionPayloadBid = ValueOf; +export type BlockAccessList = ValueOf; +export type ExecutionPayload = ValueOf; export type ExecutionPayloadEnvelope = ValueOf; export type SignedExecutionPayloadEnvelope = ValueOf; export type BeaconBlockBody = ValueOf; diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 8d5562290a0c..f79dc6043cff 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -303,7 +303,7 @@ type TypesByFork = { BlindedBeaconBlock: electra.BlindedBeaconBlock; BlindedBeaconBlockBody: electra.BlindedBeaconBlockBody; SignedBlindedBeaconBlock: electra.SignedBlindedBeaconBlock; - ExecutionPayload: deneb.ExecutionPayload; + ExecutionPayload: gloas.ExecutionPayload; ExecutionPayloadHeader: deneb.ExecutionPayloadHeader; BuilderBid: electra.BuilderBid; SignedBuilderBid: electra.SignedBuilderBid; From 9058ad06c4dec36d256e56eef49629e0acac62cb Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:47:03 -0700 Subject: [PATCH 04/39] feat: add EIP-7928 blockAccessList to engine API serialization Co-Authored-By: Claude Opus 4.6 (1M context) --- .../beacon-node/src/execution/engine/types.ts | 17 +++++++++++++++++ packages/types/src/gloas/sszTypes.ts | 9 ++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/execution/engine/types.ts b/packages/beacon-node/src/execution/engine/types.ts index cfc910d8d206..b625422616b1 100644 --- a/packages/beacon-node/src/execution/engine/types.ts +++ b/packages/beacon-node/src/execution/engine/types.ts @@ -19,6 +19,7 @@ import { capella, deneb, electra, + gloas, ssz, } from "@lodestar/types"; import {BlobAndProof} from "@lodestar/types/deneb"; @@ -180,6 +181,7 @@ export type ExecutionPayloadRpc = { withdrawals?: WithdrawalRpc[]; // Capella hardfork blobGasUsed?: QUANTITY; // DENEB excessBlobGas?: QUANTITY; // DENEB + blockAccessList?: DATA; // GLOAS:EIP-7928 }; export type WithdrawalRpc = { @@ -280,6 +282,11 @@ export function serializeExecutionPayload(fork: ForkName, data: ExecutionPayload // No changes in Electra + if (ForkSeq[fork] >= ForkSeq.gloas) { + const {blockAccessList} = data as gloas.ExecutionPayload; + payload.blockAccessList = bytesToData(blockAccessList); + } + return payload; } @@ -375,6 +382,16 @@ export function parseExecutionPayload( // No changes in Electra + if (ForkSeq[fork] >= ForkSeq.gloas) { + const {blockAccessList} = data; + if (blockAccessList == null) { + throw Error( + `blockAccessList missing for ${fork} >= gloas executionPayload number=${executionPayload.blockNumber} hash=${data.blockHash}` + ); + } + (executionPayload as gloas.ExecutionPayload).blockAccessList = dataToBytes(blockAccessList, null); + } + return {executionPayload, executionPayloadValue, blobsBundle, executionRequests, shouldOverrideBuilder}; } diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index b686c73426a6..2539969065ce 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -1,4 +1,11 @@ -import {BitVectorType, ByteListType, ContainerType, ListBasicType, ListCompositeType, VectorCompositeType} from "@chainsafe/ssz"; +import { + BitVectorType, + ByteListType, + ContainerType, + ListBasicType, + ListCompositeType, + VectorCompositeType, +} from "@chainsafe/ssz"; import { BUILDER_PENDING_WITHDRAWALS_LIMIT, BUILDER_REGISTRY_LIMIT, From 81202186b64f49f9692de45de653bd6bdeffa0f8 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:06:45 -0700 Subject: [PATCH 05/39] feat: bump engine API versions --- .../chain/produceBlock/produceBlockBody.ts | 4 ++ .../beacon-node/src/execution/engine/http.ts | 38 +++++++++++++------ .../src/execution/engine/interface.ts | 1 + .../beacon-node/src/execution/engine/mock.ts | 3 ++ .../beacon-node/src/execution/engine/types.ts | 28 +++++++++++++- packages/types/src/gloas/sszTypes.ts | 19 ++++++++++ packages/types/src/gloas/types.ts | 2 + packages/types/src/types.ts | 2 +- 8 files changed, 82 insertions(+), 15 deletions(-) diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 9f888729ce22..c11166040d0f 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -790,6 +790,10 @@ function preparePayloadAttributes( (payloadAttributes as deneb.SSEPayloadAttributes["payloadAttributes"]).parentBeaconBlockRoot = parentBlockRoot; } + if (isForkPostGloas(fork)) { + (payloadAttributes as gloas.SSEPayloadAttributes["payloadAttributes"]).slotNumber = prepareSlot; + } + return payloadAttributes; } diff --git a/packages/beacon-node/src/execution/engine/http.ts b/packages/beacon-node/src/execution/engine/http.ts index 69094ce2fa8b..af942f010dd5 100644 --- a/packages/beacon-node/src/execution/engine/http.ts +++ b/packages/beacon-node/src/execution/engine/http.ts @@ -1,5 +1,13 @@ import {Logger} from "@lodestar/logger"; -import {ForkName, ForkPostFulu, ForkPreFulu, ForkSeq, SLOTS_PER_EPOCH, isForkPostFulu} from "@lodestar/params"; +import { + ForkName, + ForkPostFulu, + ForkPreFulu, + ForkSeq, + SLOTS_PER_EPOCH, + isForkPostFulu, + isForkPostGloas, +} from "@lodestar/params"; import {BlobsBundle, ExecutionPayload, ExecutionRequests, Root, RootHex, Wei} from "@lodestar/types"; import {BlobAndProof} from "@lodestar/types/deneb"; import {BlobAndProofV2} from "@lodestar/types/fulu"; @@ -216,13 +224,15 @@ export class ExecutionEngineHttp implements IExecutionEngine { executionRequests?: ExecutionRequests ): Promise { const method = - ForkSeq[fork] >= ForkSeq.electra - ? "engine_newPayloadV4" - : ForkSeq[fork] >= ForkSeq.deneb - ? "engine_newPayloadV3" - : ForkSeq[fork] >= ForkSeq.capella - ? "engine_newPayloadV2" - : "engine_newPayloadV1"; + ForkSeq[fork] >= ForkSeq.gloas + ? "engine_newPayloadV5" + : ForkSeq[fork] >= ForkSeq.electra + ? "engine_newPayloadV4" + : ForkSeq[fork] >= ForkSeq.deneb + ? "engine_newPayloadV3" + : ForkSeq[fork] >= ForkSeq.capella + ? "engine_newPayloadV2" + : "engine_newPayloadV1"; const serializedExecutionPayload = serializeExecutionPayload(fork, executionPayload); @@ -244,7 +254,7 @@ export class ExecutionEngineHttp implements IExecutionEngine { } const serializedExecutionRequests = serializeExecutionRequests(executionRequests); engineRequest = { - method: "engine_newPayloadV4", + method: ForkSeq[fork] >= ForkSeq.gloas ? "engine_newPayloadV5" : "engine_newPayloadV4", params: [ serializedExecutionPayload, serializedVersionedHashes, @@ -347,8 +357,9 @@ export class ExecutionEngineHttp implements IExecutionEngine { ): Promise { // Once on capella, should this need to be permanently switched to v2 when payload attrs // not provided - const method = - ForkSeq[fork] >= ForkSeq.deneb + const method = isForkPostGloas(fork) + ? "engine_forkchoiceUpdatedV4" + : ForkSeq[fork] >= ForkSeq.deneb ? "engine_forkchoiceUpdatedV3" : ForkSeq[fork] >= ForkSeq.capella ? "engine_forkchoiceUpdatedV2" @@ -438,9 +449,12 @@ export class ExecutionEngineHttp implements IExecutionEngine { case ForkName.electra: method = "engine_getPayloadV4"; break; - default: + case ForkName.fulu: method = "engine_getPayloadV5"; break; + default: + method = "engine_getPayloadV6"; + break; } const payloadResponse = await this.rpc.fetchWithRetries< EngineApiRpcReturnTypes[typeof method], diff --git a/packages/beacon-node/src/execution/engine/interface.ts b/packages/beacon-node/src/execution/engine/interface.ts index 70555f16ef98..c8b7cdba6816 100644 --- a/packages/beacon-node/src/execution/engine/interface.ts +++ b/packages/beacon-node/src/execution/engine/interface.ts @@ -87,6 +87,7 @@ export type PayloadAttributes = { suggestedFeeRecipient: string; withdrawals?: capella.Withdrawal[]; parentBeaconBlockRoot?: Uint8Array; + slotNumber?: number; // EIP-7843 }; export type VersionedHashes = Uint8Array[]; diff --git a/packages/beacon-node/src/execution/engine/mock.ts b/packages/beacon-node/src/execution/engine/mock.ts index 2501ff031339..3b6342ae7b6e 100644 --- a/packages/beacon-node/src/execution/engine/mock.ts +++ b/packages/beacon-node/src/execution/engine/mock.ts @@ -132,14 +132,17 @@ export class ExecutionEngineMockBackend implements JsonRpcBackend { engine_newPayloadV2: this.notifyNewPayload.bind(this), engine_newPayloadV3: this.notifyNewPayload.bind(this), engine_newPayloadV4: this.notifyNewPayload.bind(this), + engine_newPayloadV5: this.notifyNewPayload.bind(this), engine_forkchoiceUpdatedV1: this.notifyForkchoiceUpdate.bind(this), engine_forkchoiceUpdatedV2: this.notifyForkchoiceUpdate.bind(this), engine_forkchoiceUpdatedV3: this.notifyForkchoiceUpdate.bind(this), + engine_forkchoiceUpdatedV4: this.notifyForkchoiceUpdate.bind(this), engine_getPayloadV1: this.getPayloadV1.bind(this), engine_getPayloadV2: this.getPayloadV5.bind(this), engine_getPayloadV3: this.getPayloadV5.bind(this), engine_getPayloadV4: this.getPayloadV5.bind(this), engine_getPayloadV5: this.getPayloadV5.bind(this), + engine_getPayloadV6: this.getPayloadV5.bind(this), engine_getPayloadBodiesByHashV1: this.getPayloadBodiesByHash.bind(this), engine_getPayloadBodiesByRangeV1: this.getPayloadBodiesByRange.bind(this), engine_getClientVersionV1: this.getClientVersionV1.bind(this), diff --git a/packages/beacon-node/src/execution/engine/types.ts b/packages/beacon-node/src/execution/engine/types.ts index b625422616b1..36e6737f1e34 100644 --- a/packages/beacon-node/src/execution/engine/types.ts +++ b/packages/beacon-node/src/execution/engine/types.ts @@ -51,6 +51,7 @@ export type EngineApiRpcParamTypes = { engine_newPayloadV2: [ExecutionPayloadRpc]; engine_newPayloadV3: [ExecutionPayloadRpc, VersionedHashesRpc, DATA]; engine_newPayloadV4: [ExecutionPayloadRpc, VersionedHashesRpc, DATA, ExecutionRequestsRpc]; + engine_newPayloadV5: [ExecutionPayloadRpc, VersionedHashesRpc, DATA, ExecutionRequestsRpc]; /** * 1. Object - Payload validity status with respect to the consensus rules: * - blockHash: DATA, 32 Bytes - block hash value of the payload @@ -68,6 +69,10 @@ export type EngineApiRpcParamTypes = { forkChoiceData: {headBlockHash: DATA; safeBlockHash: DATA; finalizedBlockHash: DATA}, payloadAttributes?: PayloadAttributesRpc, ]; + engine_forkchoiceUpdatedV4: [ + forkChoiceData: {headBlockHash: DATA; safeBlockHash: DATA; finalizedBlockHash: DATA}, + payloadAttributes?: PayloadAttributesRpc, + ]; /** * 1. payloadId: QUANTITY, 64 Bits - Identifier of the payload building process */ @@ -76,6 +81,7 @@ export type EngineApiRpcParamTypes = { engine_getPayloadV3: [QUANTITY]; engine_getPayloadV4: [QUANTITY]; engine_getPayloadV5: [QUANTITY]; + engine_getPayloadV6: [QUANTITY]; /** * 1. Array of DATA - Array of block_hash field values of the ExecutionPayload structure @@ -112,6 +118,7 @@ export type EngineApiRpcReturnTypes = { engine_newPayloadV2: PayloadStatus; engine_newPayloadV3: PayloadStatus; engine_newPayloadV4: PayloadStatus; + engine_newPayloadV5: PayloadStatus; engine_forkchoiceUpdatedV1: { payloadStatus: PayloadStatus; payloadId: QUANTITY | null; @@ -124,6 +131,10 @@ export type EngineApiRpcReturnTypes = { payloadStatus: PayloadStatus; payloadId: QUANTITY | null; }; + engine_forkchoiceUpdatedV4: { + payloadStatus: PayloadStatus; + payloadId: QUANTITY | null; + }; /** * payloadId | Error: QUANTITY, 64 Bits - Identifier of the payload building process */ @@ -132,6 +143,7 @@ export type EngineApiRpcReturnTypes = { engine_getPayloadV3: ExecutionPayloadResponse; engine_getPayloadV4: ExecutionPayloadResponse; engine_getPayloadV5: ExecutionPayloadResponse; + engine_getPayloadV6: ExecutionPayloadResponse; engine_getPayloadBodiesByHashV1: (ExecutionPayloadBodyRpc | null)[]; @@ -182,6 +194,7 @@ export type ExecutionPayloadRpc = { blobGasUsed?: QUANTITY; // DENEB excessBlobGas?: QUANTITY; // DENEB blockAccessList?: DATA; // GLOAS:EIP-7928 + slotNumber?: QUANTITY; // GLOAS:EIP-7843 }; export type WithdrawalRpc = { @@ -230,6 +243,8 @@ export type PayloadAttributesRpc = { withdrawals?: WithdrawalRpc[]; /** DATA, 32 Bytes - value for the parentBeaconBlockRoot to be used for building block */ parentBeaconBlockRoot?: DATA; + /** QUANTITY, 64 Bits - value for the slot number field of the new payload (EIP-7843) */ + slotNumber?: QUANTITY; }; export type ClientVersionRpc = { @@ -283,8 +298,9 @@ export function serializeExecutionPayload(fork: ForkName, data: ExecutionPayload // No changes in Electra if (ForkSeq[fork] >= ForkSeq.gloas) { - const {blockAccessList} = data as gloas.ExecutionPayload; + const {blockAccessList, slotNumber} = data as gloas.ExecutionPayload; payload.blockAccessList = bytesToData(blockAccessList); + payload.slotNumber = numToQuantity(slotNumber); } return payload; @@ -383,13 +399,19 @@ export function parseExecutionPayload( // No changes in Electra if (ForkSeq[fork] >= ForkSeq.gloas) { - const {blockAccessList} = data; + const {blockAccessList, slotNumber} = data; if (blockAccessList == null) { throw Error( `blockAccessList missing for ${fork} >= gloas executionPayload number=${executionPayload.blockNumber} hash=${data.blockHash}` ); } + if (slotNumber == null) { + throw Error( + `slotNumber missing for ${fork} >= gloas executionPayload number=${executionPayload.blockNumber} hash=${data.blockHash}` + ); + } (executionPayload as gloas.ExecutionPayload).blockAccessList = dataToBytes(blockAccessList, null); + (executionPayload as gloas.ExecutionPayload).slotNumber = quantityToNum(slotNumber); } return {executionPayload, executionPayloadValue, blobsBundle, executionRequests, shouldOverrideBuilder}; @@ -402,6 +424,7 @@ export function serializePayloadAttributes(data: PayloadAttributes): PayloadAttr suggestedFeeRecipient: data.suggestedFeeRecipient, withdrawals: data.withdrawals?.map(serializeWithdrawal), parentBeaconBlockRoot: data.parentBeaconBlockRoot ? bytesToData(data.parentBeaconBlockRoot) : undefined, + slotNumber: data.slotNumber !== undefined ? numToQuantity(data.slotNumber) : undefined, }; } @@ -418,6 +441,7 @@ export function deserializePayloadAttributes(data: PayloadAttributesRpc): Payloa suggestedFeeRecipient: data.suggestedFeeRecipient, withdrawals: data.withdrawals?.map((withdrawal) => deserializeWithdrawal(withdrawal)), parentBeaconBlockRoot: data.parentBeaconBlockRoot ? dataToBytes(data.parentBeaconBlockRoot, 32) : undefined, + slotNumber: data.slotNumber !== undefined ? quantityToNum(data.slotNumber) : undefined, }; } diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index 2539969065ce..cd816d5d049a 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -18,6 +18,7 @@ import { SLOTS_PER_HISTORICAL_ROOT, } from "@lodestar/params"; import {ssz as altairSsz} from "../altair/index.js"; +import {ssz as bellatrixSsz} from "../bellatrix/index.js"; import {ssz as capellaSsz} from "../capella/index.js"; import {ssz as denebSsz} from "../deneb/index.js"; import {ssz as electraSsz} from "../electra/index.js"; @@ -160,6 +161,7 @@ export const ExecutionPayload = new ContainerType( { ...electraSsz.ExecutionPayload.fields, blockAccessList: BlockAccessList, // New in GLOAS:EIP-7928 + slotNumber: Slot, // New in GLOAS:EIP-7843 }, {typeName: "ExecutionPayload", jsonCase: "eth2"} ); @@ -305,3 +307,20 @@ export const ExecutionPayloadEnvelopesByRangeRequest = new ContainerType( {startSlot: Slot, count: UintNum64}, {typeName: "ExecutionPayloadEnvelopesByRangeRequest", jsonCase: "eth2"} ); + +// PayloadAttributes primarily for SSE event - New in GLOAS:EIP-7843 +export const PayloadAttributes = new ContainerType( + { + ...denebSsz.PayloadAttributes.fields, + slotNumber: Slot, + }, + {typeName: "PayloadAttributes", jsonCase: "eth2"} +); + +export const SSEPayloadAttributes = new ContainerType( + { + ...bellatrixSsz.SSEPayloadAttributesCommon.fields, + payloadAttributes: PayloadAttributes, + }, + {typeName: "SSEPayloadAttributes", jsonCase: "eth2"} +); diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index 891f9eae22dc..d4f529d161db 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -25,3 +25,5 @@ export type DataColumnSidecar = ValueOf; export type DataColumnSidecars = ValueOf; export type ExecutionPayloadEnvelopesByRangeRequest = ValueOf; +export type PayloadAttributes = ValueOf; +export type SSEPayloadAttributes = ValueOf; diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index f79dc6043cff..e4cf1d2e7406 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -307,7 +307,7 @@ type TypesByFork = { ExecutionPayloadHeader: deneb.ExecutionPayloadHeader; BuilderBid: electra.BuilderBid; SignedBuilderBid: electra.SignedBuilderBid; - SSEPayloadAttributes: electra.SSEPayloadAttributes; + SSEPayloadAttributes: gloas.SSEPayloadAttributes; BlockContents: fulu.BlockContents; SignedBlockContents: fulu.SignedBlockContents; ExecutionPayloadAndBlobsBundle: fulu.ExecutionPayloadAndBlobsBundle; From bbe17c717e6963c69b4f87231d394bb96dcf88f8 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:43:16 -0700 Subject: [PATCH 06/39] Apply suggestion from @nflaig Co-authored-by: Nico Flaig --- packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index c11166040d0f..24d835a0aff4 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -790,7 +790,7 @@ function preparePayloadAttributes( (payloadAttributes as deneb.SSEPayloadAttributes["payloadAttributes"]).parentBeaconBlockRoot = parentBlockRoot; } - if (isForkPostGloas(fork)) { + if (ForkSeq[fork] >= ForkSeq.gloas) { (payloadAttributes as gloas.SSEPayloadAttributes["payloadAttributes"]).slotNumber = prepareSlot; } From a1cb95120501335f632c78a77b509d1698f61c38 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:53:14 -0700 Subject: [PATCH 07/39] update comment --- packages/types/src/gloas/sszTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index cd816d5d049a..948c702c7bf5 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -308,7 +308,7 @@ export const ExecutionPayloadEnvelopesByRangeRequest = new ContainerType( {typeName: "ExecutionPayloadEnvelopesByRangeRequest", jsonCase: "eth2"} ); -// PayloadAttributes primarily for SSE event - New in GLOAS:EIP-7843 +// PayloadAttributes primarily for SSE event export const PayloadAttributes = new ContainerType( { ...denebSsz.PayloadAttributes.fields, From 9bc6eb6ce586900d3142539c857506ec02150003 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:22:05 -0700 Subject: [PATCH 08/39] feat: implement should_apply_proposer_boost for gloas Implement the `should_apply_proposer_boost` logic from consensus-specs commit 71d1151 (PR #4807). This addresses the builder reveal safety concern where a colluding next-slot proposer could use proposer boost to override a legitimately revealed block. Changes: - Add `ptcTimeliness` and `proposerIndex` fields to ProtoBlock - Add `isBlockPtcTimely` to track PTC deadline timeliness - Add `shouldApplyProposerBoost` which withholds boost when the parent is a weak, equivocating block from the previous slot - Add `findEquivocatingBlocks` in ProtoArray to detect proposer equivocations by scanning for PTC-timely blocks at the same slot from the same proposer - Gate proposer boost in `getWeight` on `shouldApplyProposerBoost()` - Pre-gloas blocks retain unconditional boost (backward compatible) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../beacon-node/src/chain/forkChoice/index.ts | 4 ++ .../fork-choice/src/forkChoice/forkChoice.ts | 64 ++++++++++++++++++- .../fork-choice/src/protoArray/interface.ts | 7 ++ .../fork-choice/src/protoArray/protoArray.ts | 24 +++++++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index e7aaefe24b31..dd952f218fc0 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -138,6 +138,8 @@ export function initializeForkChoiceFromFinalizedState( stateRoot: toRootHex(blockHeader.stateRoot), blockRoot: toRootHex(checkpoint.root), timeliness: true, // Optimistically assume is timely + ptcTimeliness: true, // Spec: block_timeliness for anchor = [True, True] + proposerIndex: blockHeader.proposerIndex, justifiedEpoch: justifiedCheckpoint.epoch, justifiedRoot: toRootHex(justifiedCheckpoint.root), @@ -233,6 +235,8 @@ export function initializeForkChoiceFromUnfinalizedState( blockRoot: headRoot, targetRoot: headRoot, timeliness: true, // Optimistically assume is timely + ptcTimeliness: true, // Spec: block_timeliness for anchor = [True, True] + proposerIndex: blockHeader.proposerIndex, justifiedEpoch: justifiedCheckpoint.epoch, justifiedRoot: toRootHex(justifiedCheckpoint.root), diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index ac223f321db7..734df867aefd 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -508,7 +508,7 @@ export class ForkChoice implements IForkChoice { * starting from the proposerIndex */ let proposerBoost: {root: RootHex; score: number} | null = null; - if (this.opts?.proposerBoost && this.proposerBoostRoot) { + if (this.opts?.proposerBoost && this.proposerBoostRoot && this.shouldApplyProposerBoost()) { const proposerBoostScore = this.justifiedProposerBoostScore ?? getCommitteeFraction(this.fcStore.justified.totalBalance, { @@ -786,6 +786,8 @@ export class ForkChoice implements IForkChoice { targetRoot: toRootHex(targetRoot), stateRoot: toRootHex(block.stateRoot), timeliness: isTimely, + ptcTimeliness: this.isBlockPtcTimely(block, blockDelaySec), + proposerIndex: block.proposerIndex, justifiedEpoch: stateJustifiedEpoch, justifiedRoot: toRootHex(state.currentJustifiedCheckpoint.root), @@ -1436,6 +1438,66 @@ export class ForkChoice implements IForkChoice { return this.fcStore.currentSlot === block.slot && isBeforeLateBlockCutoff; } + /** + * Check if block arrived before the PTC deadline. + * Spec: gloas/fork-choice.md#record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX]) + */ + private isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean { + const isCurrentSlot = this.fcStore.currentSlot === block.slot; + const ptcThresholdMs = this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS); + return isCurrentSlot && blockDelaySec * 1000 < ptcThresholdMs; + } + + /** + * Spec: gloas/fork-choice.md#new-should_apply_proposer_boost + * Determines whether proposer boost should apply for gloas blocks. + * Returns true for pre-gloas blocks (unconditional boost). + */ + private shouldApplyProposerBoost(): boolean { + if (!this.proposerBoostRoot) { + return false; + } + + const boostedBlock = this.getBlockHexDefaultStatus(this.proposerBoostRoot); + if (!boostedBlock || !isGloasBlock(boostedBlock)) { + // Pre-gloas blocks always get boost + return true; + } + + const parentBlock = this.getBlockHexDefaultStatus(boostedBlock.parentRoot); + if (!parentBlock) { + return true; + } + + const slot = boostedBlock.slot; + + // Apply proposer boost if parent is not from the previous slot + if (parentBlock.slot + 1 < slot) { + return true; + } + + // Apply proposer boost if parent is not weak + const reorgThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, { + slotsPerEpoch: SLOTS_PER_EPOCH, + committeePercent: this.config.REORG_HEAD_WEIGHT_THRESHOLD, + }); + const parentNode = this.protoArray.getNode(parentBlock.blockRoot, PayloadStatus.PENDING); + if (parentNode === undefined || parentNode.weight >= reorgThreshold) { + // Parent is not weak + return true; + } + + // Parent is weak and from the previous slot: apply boost if there are no equivocations + // Look for other PTC-timely blocks at the same slot from the same proposer + const equivocations = this.protoArray.findEquivocatingBlocks( + parentBlock.proposerIndex, + parentBlock.slot, + parentBlock.blockRoot + ); + + return equivocations.length === 0; + } + /** * https://github.com/ethereum/consensus-specs/blob/v1.5.0/specs/phase0/fork-choice.md#is_proposing_on_time */ diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index cae46fdacd55..6ab1c88f26c7 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -135,6 +135,13 @@ export type ProtoBlock = BlockExtraMeta & { // Indicate whether block arrives in a timely manner ie. before the 4 second mark timeliness: boolean; + // Indicate whether block arrives before the PTC deadline + // Spec: gloas/fork-choice.md#record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX]) + ptcTimeliness: boolean; + + // The index of the block proposer + proposerIndex: number; + /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */ payloadStatus: PayloadStatus; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 0d98df50bb82..46d916e13b46 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1868,6 +1868,30 @@ export class ProtoArray { return node; } + /** + * Find blocks at the given slot from the same proposer that are PTC-timely, + * excluding the given block root. + * Spec: gloas/fork-choice.md#new-should_apply_proposer_boost (equivocations check) + */ + findEquivocatingBlocks(proposerIndex: number, slot: Slot, excludeRoot: RootHex): ProtoNode[] { + const result: ProtoNode[] = []; + for (const [root, variantOrArr] of this.indices.entries()) { + if (root === excludeRoot) continue; + const nodeIndex = Array.isArray(variantOrArr) ? variantOrArr[0] : variantOrArr; + if (nodeIndex === undefined) continue; + const node = this.nodes[nodeIndex]; + if ( + node !== undefined && + node.slot === slot && + node.proposerIndex === proposerIndex && + node.ptcTimeliness + ) { + result.push(node); + } + } + return result; + } + private getNodesBetween(upperIndex: number, lowerIndex: number): ProtoNode[] { const result = []; for (let index = upperIndex - 1; index > lowerIndex; index--) { From 4531a527b786afaad12f83b66176aef93e03b0ee Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:48:16 -0700 Subject: [PATCH 09/39] Skip test --- .../beacon-node/test/spec/presets/fork_choice.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index 598f323be2f9..4795a3979714 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -588,11 +588,11 @@ const forkChoiceTest = name.includes("voting_source_beyond_two_epoch") || name.includes("justified_update_always_if_better") || name.includes("justified_update_not_realized_finality") || - // TODO GLOAS: Requires should_apply_proposer_boost (gloas/fork-choice.md#new-should_apply_proposer_boost) - // which conditionally suppresses proposer boost when the parent is weak and from the previous slot. - // Pre-Gloas forks always apply boost; Gloas adds is_head_weak + equivocation checks. + // TODO GLOAS: These tests will be unskipped by https://github.com/ChainSafe/lodestar/pull/9233 (name.includes("gloas") && - name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_previous_epoch")) || + (name.includes("simple_attempted_reorg_without_enough_ffg_votes") || + name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_current_epoch") || + name.includes("include_votes_another_empty_chain_without_enough_ffg_votes_current_epoch"))) || // TODO GLOAS: These two tests are affected by the wrong proposer boost cutoff time from the // consensus-specs and thus have wrong expectation of proposer boost. Our implementation // should pass these two tests after https://github.com/ethereum/consensus-specs/pull/5095 From 5517f57831410b13e97920061c8a3f6521cd7677 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:23:06 -0700 Subject: [PATCH 10/39] Shift to cache as primary approach --- .../state-transition/src/cache/epochCache.ts | 22 ++++++---- .../src/cache/epochTransitionCache.ts | 7 ++++ packages/state-transition/src/epoch/index.ts | 2 +- .../src/epoch/processPtcWindow.ts | 38 +++++++++++++++++ .../state-transition/src/stateTransition.ts | 2 +- packages/state-transition/src/util/gloas.ts | 42 +++++-------------- 6 files changed, 71 insertions(+), 42 deletions(-) create mode 100644 packages/state-transition/src/epoch/processPtcWindow.ts diff --git a/packages/state-transition/src/cache/epochCache.ts b/packages/state-transition/src/cache/epochCache.ts index 4eefdff58eb2..8e6ccc289d2f 100644 --- a/packages/state-transition/src/cache/epochCache.ts +++ b/packages/state-transition/src/cache/epochCache.ts @@ -688,20 +688,26 @@ export class EpochCache { /** * At fork boundary, this runs post-fork logic and it happens after `upgradeState*` is called. */ - finalProcessEpoch(state: CachedBeaconStateAllForks): void { + finalProcessEpoch(state: CachedBeaconStateAllForks, epochTransitionCache: EpochTransitionCache): void { // this.epoch was updated at the end of afterProcessEpoch() const upcomingEpoch = this.epoch; const epochAfterUpcoming = upcomingEpoch + 1; this.proposersPrevEpoch = this.proposers; if (upcomingEpoch >= this.config.GLOAS_FORK_EPOCH) { - // processPtcWindow() already rotated the canonical state field earlier in process_epoch. - // Copy the previous/current epoch slices into epochCtx so validation stays on cached arrays. - ({ - previousPayloadTimelinessCommittees: this.previousPayloadTimelinessCommittees, - payloadTimelinessCommittees: this.payloadTimelinessCommittees, - nextPayloadTimelinessCommittees: this.nextPayloadTimelinessCommittees, - } = getPtcWindowEpochCacheData(state as CachedBeaconStateGloas)); + if (epochTransitionCache.nextEpochPayloadTimelinessCommittees) { + // shift arrays from transition cache + this.previousPayloadTimelinessCommittees = this.payloadTimelinessCommittees; + this.payloadTimelinessCommittees = this.nextPayloadTimelinessCommittees; + this.nextPayloadTimelinessCommittees = epochTransitionCache.nextEpochPayloadTimelinessCommittees; + } else { + // Fork boundary: processPtcWindow didn't run, read from freshly initialized state.ptcWindow + ({ + previousPayloadTimelinessCommittees: this.previousPayloadTimelinessCommittees, + payloadTimelinessCommittees: this.payloadTimelinessCommittees, + nextPayloadTimelinessCommittees: this.nextPayloadTimelinessCommittees, + } = getPtcWindowEpochCacheData(state as CachedBeaconStateGloas)); + } } if (upcomingEpoch >= this.config.FULU_FORK_EPOCH) { // Populate proposer cache with lookahead from state diff --git a/packages/state-transition/src/cache/epochTransitionCache.ts b/packages/state-transition/src/cache/epochTransitionCache.ts index 01d7e94ff153..0cc2e3276118 100644 --- a/packages/state-transition/src/cache/epochTransitionCache.ts +++ b/packages/state-transition/src/cache/epochTransitionCache.ts @@ -158,6 +158,12 @@ export interface EpochTransitionCache { */ nextShuffling: EpochShuffling | null; + /** + * Pre-computed PTC for epoch N + MIN_SEED_LOOKAHEAD + 1, populated by processPtcWindow (Gloas+). + * Used by finalProcessEpoch to shift PTC arrays in epoch cache without reading from state. + */ + nextEpochPayloadTimelinessCommittees: Uint32Array[] | null; + /** * Altair specific, this is total active balances for the next epoch. * This is only used in `afterProcessEpoch` to compute base reward and sync participant reward. @@ -502,6 +508,7 @@ export function beforeProcessEpoch( indicesToEject, nextShufflingActiveIndices, nextShuffling: null, + nextEpochPayloadTimelinessCommittees: null, // to be updated in processEffectiveBalanceUpdates nextEpochTotalActiveBalanceByIncrement: 0, isActivePrevEpoch, diff --git a/packages/state-transition/src/epoch/index.ts b/packages/state-transition/src/epoch/index.ts index 1792b52d5587..40579a72095c 100644 --- a/packages/state-transition/src/epoch/index.ts +++ b/packages/state-transition/src/epoch/index.ts @@ -16,7 +16,6 @@ import { CachedBeaconStatePhase0, EpochTransitionCache, } from "../types.js"; -import {processPtcWindow} from "../util/gloas.js"; import {processBuilderPendingPayments} from "./processBuilderPendingPayments.js"; import {processEffectiveBalanceUpdates} from "./processEffectiveBalanceUpdates.js"; import {processEth1DataReset} from "./processEth1DataReset.js"; @@ -29,6 +28,7 @@ import {processParticipationRecordUpdates} from "./processParticipationRecordUpd import {processPendingConsolidations} from "./processPendingConsolidations.js"; import {processPendingDeposits} from "./processPendingDeposits.js"; import {processProposerLookahead} from "./processProposerLookahead.js"; +import {processPtcWindow} from "./processPtcWindow.js"; import {processRandaoMixesReset} from "./processRandaoMixesReset.js"; import {processRegistryUpdates} from "./processRegistryUpdates.js"; import {processRewardsAndPenalties} from "./processRewardsAndPenalties.js"; diff --git a/packages/state-transition/src/epoch/processPtcWindow.ts b/packages/state-transition/src/epoch/processPtcWindow.ts new file mode 100644 index 000000000000..782d0b256683 --- /dev/null +++ b/packages/state-transition/src/epoch/processPtcWindow.ts @@ -0,0 +1,38 @@ +import {MIN_SEED_LOOKAHEAD} from "@lodestar/params"; +import {ssz} from "@lodestar/types"; +import {CachedBeaconStateGloas, EpochTransitionCache} from "../types.js"; +import {computeEpochShuffling} from "../util/epochShuffling.js"; +import {computePayloadTimelinessCommitteesForEpoch} from "../util/seed.js"; + +/** + * Update the `ptc_window` field in the beacon state by shifting out the oldest epoch's + * PTC entries and appending newly computed entries for the next lookahead epoch. + * Stashes the computed PTCs in the transition cache for finalProcessEpoch to shift + * into the epoch cache without reading from state. + * + * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.4/specs/gloas/beacon-chain.md#process_ptc_window + */ +export function processPtcWindow(state: CachedBeaconStateGloas, cache: EpochTransitionCache): void { + const nextEpoch = state.epochCtx.epoch + MIN_SEED_LOOKAHEAD + 1; + const nextShuffling = + cache.nextShuffling ?? computeEpochShuffling(state, cache.nextShufflingActiveIndices, nextEpoch); + cache.nextShuffling = nextShuffling; + + const nextEpochPtcs = computePayloadTimelinessCommitteesForEpoch( + state, + nextEpoch, + nextShuffling.committees, + state.epochCtx.effectiveBalanceIncrements + ); + + // Stash for finalProcessEpoch to shift into epoch cache + cache.nextEpochPayloadTimelinessCommittees = nextEpochPtcs; + + // Write shifted window to state: current(N) + next(N+1) + newlyComputed(N+2) + // From the perspective of upcoming epoch N+1, this is previous + current + next + state.ptcWindow = ssz.gloas.PtcWindow.toViewDU([ + ...state.epochCtx.payloadTimelinessCommittees.map((c) => Array.from(c)), + ...state.epochCtx.nextPayloadTimelinessCommittees.map((c) => Array.from(c)), + ...nextEpochPtcs.map((c) => Array.from(c)), + ]); +} diff --git a/packages/state-transition/src/stateTransition.ts b/packages/state-transition/src/stateTransition.ts index e9b0ffc21c2c..12ffce065487 100644 --- a/packages/state-transition/src/stateTransition.ts +++ b/packages/state-transition/src/stateTransition.ts @@ -283,7 +283,7 @@ function processSlotsWithTransientCache( { const timer = metrics?.epochTransitionStepTime.startTimer({step: EpochTransitionStep.finalProcessEpoch}); // last step to prepare epoch data that depends on the upgraded state, for example proposerLookahead of BeaconStateFulu - postState.epochCtx.finalProcessEpoch(postState); + postState.epochCtx.finalProcessEpoch(postState, epochTransitionCache); timer?.(); } diff --git a/packages/state-transition/src/util/gloas.ts b/packages/state-transition/src/util/gloas.ts index 141ed0fcea96..f620bbab94a9 100644 --- a/packages/state-transition/src/util/gloas.ts +++ b/packages/state-transition/src/util/gloas.ts @@ -10,10 +10,9 @@ import { PTC_SIZE, SLOTS_PER_EPOCH, } from "@lodestar/params"; -import {BuilderIndex, Epoch, ValidatorIndex, gloas, ssz} from "@lodestar/types"; +import {BuilderIndex, Epoch, ValidatorIndex, gloas} from "@lodestar/types"; import {AttestationData} from "@lodestar/types/phase0"; import {byteArrayEquals} from "@lodestar/utils"; -import type {EpochTransitionCache} from "../cache/epochTransitionCache.js"; import type {CachedBeaconStateFulu, CachedBeaconStateGloas} from "../cache/stateCache.js"; import type {IBeaconStateViewGloas} from "../stateView/interface.js"; import {getBlockRootAtSlot} from "./blockRoot.js"; @@ -201,42 +200,21 @@ export function initializePtcWindow(state: CachedBeaconStateFulu): number[][] { return ptcWindow; } -export function processPtcWindow(state: CachedBeaconStateGloas, cache: EpochTransitionCache): void { - const nextEpoch = state.epochCtx.epoch + MIN_SEED_LOOKAHEAD + 1; - const nextShuffling = - cache.nextShuffling ?? computeEpochShuffling(state, cache.nextShufflingActiveIndices, nextEpoch); - cache.nextShuffling = nextShuffling; - - const remainingPtcWindow = state.ptcWindow.toValue().slice(SLOTS_PER_EPOCH); - const nextEpochPtcs = computePayloadTimelinessCommitteesForEpoch( - state, - nextEpoch, - nextShuffling.committees, - state.epochCtx.effectiveBalanceIncrements - ); - - state.ptcWindow = ssz.gloas.PtcWindow.toViewDU([ - ...remainingPtcWindow, - ...nextEpochPtcs.map((committee) => Array.from(committee)), - ]); -} - export function getPtcWindowEpochCacheData(state: CachedBeaconStateGloas): { previousPayloadTimelinessCommittees: Uint32Array[]; payloadTimelinessCommittees: Uint32Array[]; nextPayloadTimelinessCommittees: Uint32Array[]; } { - const ptcWindow = state.ptcWindow.toValue(); + const toUint32Arrays = (views: ReturnType) => + views.map((v) => Uint32Array.from(v.getAll())); + + const previousPtcWindow = state.ptcWindow.getReadonlyByRange(0, SLOTS_PER_EPOCH); + const currentPtcWindow = state.ptcWindow.getReadonlyByRange(SLOTS_PER_EPOCH, SLOTS_PER_EPOCH); + const nextPtcWindow = state.ptcWindow.getReadonlyByRange(2 * SLOTS_PER_EPOCH, SLOTS_PER_EPOCH); return { - previousPayloadTimelinessCommittees: ptcWindow - .slice(0, SLOTS_PER_EPOCH) - .map((committee: ValidatorIndex[]) => Uint32Array.from(committee)), - payloadTimelinessCommittees: ptcWindow - .slice(SLOTS_PER_EPOCH, 2 * SLOTS_PER_EPOCH) - .map((committee: ValidatorIndex[]) => Uint32Array.from(committee)), - nextPayloadTimelinessCommittees: ptcWindow - .slice(2 * SLOTS_PER_EPOCH, 3 * SLOTS_PER_EPOCH) - .map((committee: ValidatorIndex[]) => Uint32Array.from(committee)), + previousPayloadTimelinessCommittees: toUint32Arrays(previousPtcWindow), + payloadTimelinessCommittees: toUint32Arrays(currentPtcWindow), + nextPayloadTimelinessCommittees: toUint32Arrays(nextPtcWindow), }; } From 000b21cfc3496726e7da96fda205f61fef980b19 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:28:12 -0700 Subject: [PATCH 11/39] Address @nflaig's comment --- packages/beacon-node/test/spec/utils/specTestIterator.ts | 3 ++- packages/state-transition/src/epoch/processPtcWindow.ts | 1 + packages/state-transition/src/util/gloas.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/test/spec/utils/specTestIterator.ts b/packages/beacon-node/test/spec/utils/specTestIterator.ts index 138f495d5e00..de769ccfff49 100644 --- a/packages/beacon-node/test/spec/utils/specTestIterator.ts +++ b/packages/beacon-node/test/spec/utils/specTestIterator.ts @@ -73,7 +73,8 @@ export const defaultSkipOpts: SkipOpts = { /^fulu\/light_client\/single_merkle_proof\/BeaconBlockBody.*/, /^.+\/light_client\/data_collection\/.*/, /^gloas\/ssz_static\/ForkChoiceNode.*$/, - // Ignore the partial data column container additions for now; they are outside the alpha.4/#4979 runtime scope. + // Ignore the partial data column container additions for now. Unskip them when + // cell level DAS is ready /^fulu\/ssz_static\/PartialDataColumn(Header|PartsMetadata|Sidecar)\/.*$/, /^gloas\/ssz_static\/PartialDataColumn(Header|PartsMetadata|Sidecar)\/.*$/, ], diff --git a/packages/state-transition/src/epoch/processPtcWindow.ts b/packages/state-transition/src/epoch/processPtcWindow.ts index 782d0b256683..b2afede190ef 100644 --- a/packages/state-transition/src/epoch/processPtcWindow.ts +++ b/packages/state-transition/src/epoch/processPtcWindow.ts @@ -30,6 +30,7 @@ export function processPtcWindow(state: CachedBeaconStateGloas, cache: EpochTran // Write shifted window to state: current(N) + next(N+1) + newlyComputed(N+2) // From the perspective of upcoming epoch N+1, this is previous + current + next + // TODO: Remove Array.from() once @chainsafe/ssz is upgraded to v1.3.1+ (accepts Uint32Array directly) state.ptcWindow = ssz.gloas.PtcWindow.toViewDU([ ...state.epochCtx.payloadTimelinessCommittees.map((c) => Array.from(c)), ...state.epochCtx.nextPayloadTimelinessCommittees.map((c) => Array.from(c)), diff --git a/packages/state-transition/src/util/gloas.ts b/packages/state-transition/src/util/gloas.ts index f620bbab94a9..a6ef7511bd25 100644 --- a/packages/state-transition/src/util/gloas.ts +++ b/packages/state-transition/src/util/gloas.ts @@ -193,6 +193,7 @@ export function initializePtcWindow(state: CachedBeaconStateFulu): number[][] { epoch, shuffling.committees, state.epochCtx.effectiveBalanceIncrements + // TODO: Remove Array.from() once @chainsafe/ssz is upgraded to v1.3.1+ (accepts Uint32Array directly) ).map((committee) => Array.from(committee)) ); } From 06d23f3f04e7ecd4d62b14655e823a466eb3960c Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:59:17 -0700 Subject: [PATCH 12/39] lint --- packages/beacon-node/test/spec/utils/specTestIterator.ts | 2 +- packages/state-transition/src/util/gloas.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/test/spec/utils/specTestIterator.ts b/packages/beacon-node/test/spec/utils/specTestIterator.ts index de769ccfff49..583a84006bb5 100644 --- a/packages/beacon-node/test/spec/utils/specTestIterator.ts +++ b/packages/beacon-node/test/spec/utils/specTestIterator.ts @@ -73,7 +73,7 @@ export const defaultSkipOpts: SkipOpts = { /^fulu\/light_client\/single_merkle_proof\/BeaconBlockBody.*/, /^.+\/light_client\/data_collection\/.*/, /^gloas\/ssz_static\/ForkChoiceNode.*$/, - // Ignore the partial data column container additions for now. Unskip them when + // Ignore the partial data column container additions for now. Unskip them when // cell level DAS is ready /^fulu\/ssz_static\/PartialDataColumn(Header|PartsMetadata|Sidecar)\/.*$/, /^gloas\/ssz_static\/PartialDataColumn(Header|PartsMetadata|Sidecar)\/.*$/, diff --git a/packages/state-transition/src/util/gloas.ts b/packages/state-transition/src/util/gloas.ts index a6ef7511bd25..ad7e04157e1f 100644 --- a/packages/state-transition/src/util/gloas.ts +++ b/packages/state-transition/src/util/gloas.ts @@ -193,7 +193,7 @@ export function initializePtcWindow(state: CachedBeaconStateFulu): number[][] { epoch, shuffling.committees, state.epochCtx.effectiveBalanceIncrements - // TODO: Remove Array.from() once @chainsafe/ssz is upgraded to v1.3.1+ (accepts Uint32Array directly) + // TODO: Remove Array.from() once @chainsafe/ssz is upgraded to v1.3.1+ (accepts Uint32Array directly) ).map((committee) => Array.from(committee)) ); } From b4e95779532856ea43cefbc11a5d84b91f4e8051 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:06:16 -0700 Subject: [PATCH 13/39] Revert changes to initializeBeaconStateFromEth1 --- packages/state-transition/src/util/genesis.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/packages/state-transition/src/util/genesis.ts b/packages/state-transition/src/util/genesis.ts index dd37d6ccf644..8ee6b9fda3a2 100644 --- a/packages/state-transition/src/util/genesis.ts +++ b/packages/state-transition/src/util/genesis.ts @@ -15,17 +15,10 @@ import {processDeposit} from "../block/processDeposit.js"; import {EpochCacheImmutableData} from "../cache/epochCache.js"; import {createCachedBeaconState} from "../cache/stateCache.js"; import {increaseBalance} from "../index.js"; -import { - BeaconStateAllForks, - CachedBeaconStateAllForks, - CachedBeaconStateElectra, - CachedBeaconStateFulu, -} from "../types.js"; +import {BeaconStateAllForks, CachedBeaconStateAllForks, CachedBeaconStateElectra} from "../types.js"; import {newFilledArray} from "./array.js"; import {getTemporaryBlockHeader} from "./blockRoot.js"; import {computeEpochAtSlot} from "./epoch.js"; -import {initializeProposerLookahead} from "./fulu.js"; -import {initializePtcWindow} from "./gloas.js"; import {getNextSyncCommittee} from "./syncCommittee.js"; import {getActiveValidatorIndices, getMaxEffectiveBalance} from "./validator.js"; @@ -333,23 +326,15 @@ export function initializeBeaconStateFromEth1( stateFulu.latestExecutionPayloadHeader = (executionPayloadHeader as CompositeViewDU) ?? ssz.fulu.ExecutionPayloadHeader.defaultViewDU(); - stateFulu.proposerLookahead = ssz.fulu.ProposerLookahead.toViewDU( - initializeProposerLookahead(state as CachedBeaconStateElectra) - ); } if (fork >= ForkSeq.gloas) { const stateGloas = state as CompositeViewDU; stateGloas.fork.previousVersion = config.GLOAS_FORK_VERSION; stateGloas.fork.currentVersion = config.GLOAS_FORK_VERSION; - stateGloas.ptcWindow = ssz.gloas.PtcWindow.toViewDU(initializePtcWindow(state as CachedBeaconStateFulu)); } state.commit(); - if (fork >= ForkSeq.fulu) { - return createCachedBeaconState(state as BeaconStateAllForks, immutableData, {skipSyncCommitteeCache: true}); - } - return state; } From c4282b9fd1d1b754c940066d5b08a9271796a390 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:14:53 -0700 Subject: [PATCH 14/39] Revert changes to sanity test --- packages/beacon-node/test/spec/presets/sanity.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/test/spec/presets/sanity.test.ts b/packages/beacon-node/test/spec/presets/sanity.test.ts index 4e128e9b238b..1d35ab48ecf8 100644 --- a/packages/beacon-node/test/spec/presets/sanity.test.ts +++ b/packages/beacon-node/test/spec/presets/sanity.test.ts @@ -46,7 +46,7 @@ const sanitySlots: TestRunnerFn = (for post: ssz[fork].BeaconState, }, shouldError: (testCase) => !testCase.post, - timeout: 60000, + timeout: 30000, getExpected: (testCase) => testCase.post, expectFunc: (_testCase, expected, actual) => { expectEqualBeaconState(fork, expected, actual); From 1332882f7b6d25f0eedfea24f8446be98442296f Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:13:38 -0700 Subject: [PATCH 15/39] init Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api/impl/validator/index.ts | 5 ++--- .../frequencyStateArchiveStrategy.ts | 6 ++---- .../src/chain/blocks/importBlock.ts | 7 +------ .../src/chain/blocks/importExecutionPayload.ts | 4 ++-- packages/beacon-node/src/chain/chain.ts | 14 +++++++------- .../beacon-node/src/chain/prepareNextSlot.ts | 8 ++------ packages/beacon-node/src/chain/regen/errors.ts | 7 +------ .../beacon-node/src/chain/regen/interface.ts | 15 +++++---------- packages/beacon-node/src/chain/regen/queued.ts | 18 +++++------------- packages/beacon-node/src/chain/regen/regen.ts | 8 -------- .../stateCache/persistentCheckpointsCache.ts | 15 +-------------- .../beacon-node/src/chain/stateCache/types.ts | 3 --- .../beacon-node/test/utils/node/simTest.ts | 4 +--- 13 files changed, 29 insertions(+), 85 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index af64d4c922ef..1ea746e169c4 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -70,7 +70,7 @@ import {ChainEvent, CommonBlockBody} from "../../../chain/index.js"; import {PREPARE_NEXT_SLOT_BPS} from "../../../chain/prepareNextSlot.js"; import {BlockType, ProduceFullDeneb, ProduceFullGloas} from "../../../chain/produceBlock/index.js"; import {RegenCaller} from "../../../chain/regen/index.js"; -import {CheckpointHexPayload} from "../../../chain/stateCache/types.js"; +import {CheckpointHex} from "../../../chain/stateCache/types.js"; import {validateApiAggregateAndProof} from "../../../chain/validation/index.js"; import {validateSyncCommitteeGossipContributionAndProof} from "../../../chain/validation/syncCommitteeContributionAndProof.js"; import {ZERO_HASH} from "../../../constants/index.js"; @@ -301,7 +301,7 @@ export function getValidatorApi( * | * prepareNextSlot (4s before next slot) */ - async function waitForCheckpointState(cpHex: CheckpointHexPayload): Promise { + async function waitForCheckpointState(cpHex: CheckpointHex): Promise { const cpState = chain.regen.getCheckpointStateSync(cpHex); if (cpState) { return cpState; @@ -1113,7 +1113,6 @@ export function getValidatorApi( const cpState = await waitForCheckpointState({ rootHex: head.blockRoot, epoch, - payloadPresent: head.payloadStatus === PayloadStatus.FULL, }); if (cpState) { state = cpState; diff --git a/packages/beacon-node/src/chain/archiveStore/strategies/frequencyStateArchiveStrategy.ts b/packages/beacon-node/src/chain/archiveStore/strategies/frequencyStateArchiveStrategy.ts index 09be1942c175..12ae1d162f59 100644 --- a/packages/beacon-node/src/chain/archiveStore/strategies/frequencyStateArchiveStrategy.ts +++ b/packages/beacon-node/src/chain/archiveStore/strategies/frequencyStateArchiveStrategy.ts @@ -9,7 +9,6 @@ import {AllocSource, BufferPool} from "../../../util/bufferPool.js"; import {getStateSlotFromBytes} from "../../../util/multifork.js"; import {IStateRegenerator} from "../../regen/interface.js"; import {serializeState} from "../../serializeState.js"; -import {fcCheckpointToHexPayload} from "../../stateCache/persistentCheckpointsCache.js"; import {StateArchiveStrategy, StatesArchiveOpts} from "../interface.js"; /** @@ -108,9 +107,8 @@ export class FrequencyStateArchiveStrategy implements StateArchiveStrategy { async archiveState(finalized: CheckpointWithPayloadStatus, metrics?: Metrics | null): Promise { // starting from Mar 2024, the finalized state could be from disk or in memory let timer = metrics?.processFinalizedCheckpoint.frequencyStateArchive.startTimer(); - // Convert fork-choice checkpoint to beacon-node checkpoint with payloadPresent - const finalizedHexPayload = fcCheckpointToHexPayload(finalized); - const finalizedStateOrBytes = await this.regen.getCheckpointStateOrBytes(finalizedHexPayload); + const finalizedHex = {epoch: finalized.epoch, rootHex: finalized.rootHex}; + const finalizedStateOrBytes = await this.regen.getCheckpointStateOrBytes(finalizedHex); timer?.({step: FrequencyStateArchiveStep.GetFinalizedState}); const {rootHex} = finalized; diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index f352e0979417..e9dd1ee0b4f1 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -8,7 +8,6 @@ import { ForkChoiceErrorCode, NotReorgedReason, getSafeExecutionBlockHash, - isGloasBlock, } from "@lodestar/fork-choice"; import { ForkPostAltair, @@ -134,10 +133,6 @@ export async function importBlock( // This adds the state necessary to process the next block // Some block event handlers require state being in state cache so need to do this before emitting EventType.block - // Pre-Gloas: blockSummary.payloadStatus is always FULL, payloadPresent = true - // Post-Gloas: blockSummary.payloadStatus is always PENDING, so payloadPresent = false (block state only, no payload processing yet) - const payloadPresent = !isGloasBlock(blockSummary); - // processState manages both block state and payload state variants together for memory/disk management this.regen.processBlockState(blockRootHex, postBlockState); // For Gloas blocks, create PayloadEnvelopeInput so it's available for later payload import @@ -514,7 +509,7 @@ export async function importBlock( // Cache state to preserve epoch transition work const checkpointState = postBlockState; const cp = getCheckpointFromState(checkpointState); - this.regen.addCheckpointState(cp, checkpointState, payloadPresent); + this.regen.addCheckpointState(cp, checkpointState); // consumers should not mutate state ever this.emitter.emit(ChainEvent.checkpoint, cp, checkpointState); diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index c50aea6face8..46b719559f69 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -240,10 +240,10 @@ export async function importExecutionPayload( ); // 8. Cache payload state - this.regen.processPayloadState(postPayloadState); + this.regen.processBlockState(blockRootHex, postPayloadState); if (postPayloadState.slot % SLOTS_PER_EPOCH === 0) { const {checkpoint} = postPayloadState.computeAnchorCheckpoint(); - this.regen.addCheckpointState(checkpoint, postPayloadState, true); + this.regen.addCheckpointState(checkpoint, postPayloadState); } // 9. Record metrics for payload envelope and column sources diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index f27bc165085e..1efa05aa20b0 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -118,7 +118,7 @@ import {DbCPStateDatastore, checkpointToDatastoreKey} from "./stateCache/datasto import {FileCPStateDatastore} from "./stateCache/datastore/file.js"; import {CPStateDatastore} from "./stateCache/datastore/types.js"; import {FIFOBlockStateCache} from "./stateCache/fifoBlockStateCache.js"; -import {PersistentCheckpointStateCache, fcCheckpointToHexPayload} from "./stateCache/persistentCheckpointsCache.js"; +import {PersistentCheckpointStateCache} from "./stateCache/persistentCheckpointsCache.js"; import {CheckpointStateCache} from "./stateCache/types.js"; import {ValidatorMonitor} from "./validatorMonitor.js"; @@ -685,8 +685,8 @@ export class BeaconChain implements IBeaconChain { checkpoint: CheckpointWithPayloadStatus ): {state: IBeaconStateView; executionOptimistic: boolean; finalized: boolean} | null { // finalized or justified checkpoint states maynot be available with PersistentCheckpointStateCache, use getCheckpointStateOrBytes() api to get Uint8Array - const checkpointHexPayload = fcCheckpointToHexPayload(checkpoint); - const cachedStateCtx = this.regen.getCheckpointStateSync(checkpointHexPayload); + const checkpointHex = {epoch: checkpoint.epoch, rootHex: checkpoint.rootHex}; + const cachedStateCtx = this.regen.getCheckpointStateSync(checkpointHex); if (cachedStateCtx) { const block = this.forkChoice.getBlockDefaultStatus( ssz.phase0.BeaconBlockHeader.hashTreeRoot(cachedStateCtx.latestBlockHeader) @@ -705,8 +705,8 @@ export class BeaconChain implements IBeaconChain { async getStateOrBytesByCheckpoint( checkpoint: CheckpointWithPayloadStatus ): Promise<{state: IBeaconStateView | Uint8Array; executionOptimistic: boolean; finalized: boolean} | null> { - const checkpointHexPayload = fcCheckpointToHexPayload(checkpoint); - const cachedStateCtx = await this.regen.getCheckpointStateOrBytes(checkpointHexPayload); + const checkpointHex = {epoch: checkpoint.epoch, rootHex: checkpoint.rootHex}; + const cachedStateCtx = await this.regen.getCheckpointStateOrBytes(checkpointHex); if (cachedStateCtx) { const block = this.forkChoice.getBlockDefaultStatus(checkpoint.root); const finalizedEpoch = this.forkChoice.getFinalizedCheckpoint().epoch; @@ -1338,8 +1338,8 @@ export class BeaconChain implements IBeaconChain { checkpoint: CheckpointWithPayloadStatus, blockState: IBeaconStateView ): {state: IBeaconStateView; stateId: string; shouldWarn: boolean} { - const checkpointHexPayload = fcCheckpointToHexPayload(checkpoint); - const state = this.regen.getCheckpointStateSync(checkpointHexPayload); + const checkpointHex = {epoch: checkpoint.epoch, rootHex: checkpoint.rootHex}; + const state = this.regen.getCheckpointStateSync(checkpointHex); if (state) { return {state, stateId: "checkpoint_state", shouldWarn: false}; } diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index fb049f35571d..f58d42be4931 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -1,6 +1,6 @@ import {routes} from "@lodestar/api"; import {ChainForkConfig} from "@lodestar/config"; -import {PayloadStatus, getSafeExecutionBlockHash} from "@lodestar/fork-choice"; +import {getSafeExecutionBlockHash} from "@lodestar/fork-choice"; import {ForkPostBellatrix, ForkSeq, SLOTS_PER_EPOCH, isForkPostBellatrix} from "@lodestar/params"; import { IBeaconStateView, @@ -217,11 +217,7 @@ export class PrepareNextSlotScheduler { // + if next slot is a skipped slot, it'd help getting target checkpoint state faster to validate attestations if (isEpochTransition) { this.metrics?.precomputeNextEpochTransition.count.inc({result: "success"}, 1); - // Determine payloadPresent from head block's payload status - // Pre-Gloas: payloadStatus is always FULL → payloadPresent = true - // Post-Gloas: FULL → true, EMPTY → false, PENDING → false (conservative, treat as block state) - const payloadPresent = headBlock.payloadStatus === PayloadStatus.FULL; - const previousHits = this.chain.regen.updatePreComputedCheckpoint(headRoot, nextEpoch, payloadPresent); + const previousHits = this.chain.regen.updatePreComputedCheckpoint(headRoot, nextEpoch); if (previousHits === 0) { this.metrics?.precomputeNextEpochTransition.waste.inc(); } diff --git a/packages/beacon-node/src/chain/regen/errors.ts b/packages/beacon-node/src/chain/regen/errors.ts index 6352d765ea11..eb41e8321da3 100644 --- a/packages/beacon-node/src/chain/regen/errors.ts +++ b/packages/beacon-node/src/chain/regen/errors.ts @@ -1,4 +1,3 @@ -import {PayloadStatus} from "@lodestar/fork-choice"; import {Root, RootHex, Slot} from "@lodestar/types"; export enum RegenErrorCode { @@ -10,8 +9,6 @@ export enum RegenErrorCode { BLOCK_NOT_IN_DB = "REGEN_ERROR_BLOCK_NOT_IN_DB", STATE_TRANSITION_ERROR = "REGEN_ERROR_STATE_TRANSITION_ERROR", INVALID_STATE_ROOT = "REGEN_ERROR_INVALID_STATE_ROOT", - UNEXPECTED_PAYLOAD_STATUS = "REGEN_ERROR_UNEXPECTED_PAYLOAD_STATUS", - INTERNAL_ERROR = "REGEN_ERROR_INTERNAL_ERROR", } export type RegenErrorType = @@ -22,9 +19,7 @@ export type RegenErrorType = | {code: RegenErrorCode.TOO_MANY_BLOCK_PROCESSED; stateRoot: RootHex | Root} | {code: RegenErrorCode.BLOCK_NOT_IN_DB; blockRoot: RootHex | Root} | {code: RegenErrorCode.STATE_TRANSITION_ERROR; error: Error} - | {code: RegenErrorCode.INVALID_STATE_ROOT; slot: Slot; expected: RootHex; actual: RootHex} - | {code: RegenErrorCode.UNEXPECTED_PAYLOAD_STATUS; blockRoot: RootHex | Root; payloadStatus: PayloadStatus} - | {code: RegenErrorCode.INTERNAL_ERROR; message: string}; + | {code: RegenErrorCode.INVALID_STATE_ROOT; slot: Slot; expected: RootHex; actual: RootHex}; export class RegenError extends Error { type: RegenErrorType; diff --git a/packages/beacon-node/src/chain/regen/interface.ts b/packages/beacon-node/src/chain/regen/interface.ts index a4e981442771..fc01a73f4d7e 100644 --- a/packages/beacon-node/src/chain/regen/interface.ts +++ b/packages/beacon-node/src/chain/regen/interface.ts @@ -2,7 +2,7 @@ import {routes} from "@lodestar/api"; import {ProtoBlock} from "@lodestar/fork-choice"; import {IBeaconStateView} from "@lodestar/state-transition"; import {BeaconBlock, Epoch, RootHex, Slot, phase0} from "@lodestar/types"; -import {CheckpointHexPayload} from "../stateCache/types.js"; +import {CheckpointHex} from "../stateCache/types.js"; export enum RegenCaller { getDuties = "getDuties", @@ -40,20 +40,15 @@ export interface IStateRegenerator extends IStateRegeneratorInternal { dumpCacheSummary(): routes.lodestar.StateCacheItem[]; getStateSync(stateRoot: RootHex): IBeaconStateView | null; getPreStateSync(block: BeaconBlock): IBeaconStateView | null; - getCheckpointStateOrBytes(cp: CheckpointHexPayload): Promise; - getCheckpointStateSync(cp: CheckpointHexPayload): IBeaconStateView | null; + getCheckpointStateOrBytes(cp: CheckpointHex): Promise; + getCheckpointStateSync(cp: CheckpointHex): IBeaconStateView | null; getClosestHeadState(head: ProtoBlock): IBeaconStateView | null; pruneOnCheckpoint(finalizedEpoch: Epoch, justifiedEpoch: Epoch, headStateRoot: RootHex): void; pruneOnFinalized(finalizedEpoch: Epoch): void; processBlockState(blockRootHex: RootHex, postState: IBeaconStateView): void; - processPayloadState(payloadState: IBeaconStateView): void; - /** - * payloadPresent is true if this is payload state, false if block state. - * payloadPresent is always true for pre-gloas. - */ - addCheckpointState(cp: phase0.Checkpoint, item: IBeaconStateView, payloadPresent: boolean): void; + addCheckpointState(cp: phase0.Checkpoint, item: IBeaconStateView): void; updateHeadState(newHead: ProtoBlock, maybeHeadState: IBeaconStateView): void; - updatePreComputedCheckpoint(rootHex: RootHex, epoch: Epoch, payloadPresent: boolean): number | null; + updatePreComputedCheckpoint(rootHex: RootHex, epoch: Epoch): number | null; } /** diff --git a/packages/beacon-node/src/chain/regen/queued.ts b/packages/beacon-node/src/chain/regen/queued.ts index 8652deb6a3a0..5df5287491dd 100644 --- a/packages/beacon-node/src/chain/regen/queued.ts +++ b/packages/beacon-node/src/chain/regen/queued.ts @@ -5,7 +5,7 @@ import {BeaconBlock, Epoch, RootHex, Slot, isGloasBeaconBlock, phase0} from "@lo import {Logger, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/index.js"; import {JobItemQueue} from "../../util/queue/index.js"; -import {BlockStateCache, CheckpointHexPayload, CheckpointStateCache} from "../stateCache/types.js"; +import {BlockStateCache, CheckpointHex, CheckpointStateCache} from "../stateCache/types.js"; import {RegenError, RegenErrorCode} from "./errors.js"; import { IStateRegenerator, @@ -125,14 +125,14 @@ export class QueuedStateRegenerator implements IStateRegenerator { return null; } - async getCheckpointStateOrBytes(cp: CheckpointHexPayload): Promise { + async getCheckpointStateOrBytes(cp: CheckpointHex): Promise { return this.checkpointStateCache.getStateOrBytes(cp); } /** * Get checkpoint state from cache */ - getCheckpointStateSync(cp: CheckpointHexPayload): IBeaconStateView | null { + getCheckpointStateSync(cp: CheckpointHex): IBeaconStateView | null { return this.checkpointStateCache.get(cp); } @@ -160,15 +160,7 @@ export class QueuedStateRegenerator implements IStateRegenerator { }); } - /** - * Process payload state for caching after importing execution payload. - */ - processPayloadState(payloadState: IBeaconStateView): void { - // Add payload state to block state cache (keyed by payload state root) - this.blockStateCache.add(payloadState); - } - - addCheckpointState(cp: phase0.Checkpoint, item: IBeaconStateView, _payloadPresent: boolean): void { + addCheckpointState(cp: phase0.Checkpoint, item: IBeaconStateView): void { this.checkpointStateCache.add(cp, item); } @@ -205,7 +197,7 @@ export class QueuedStateRegenerator implements IStateRegenerator { } } - updatePreComputedCheckpoint(rootHex: RootHex, epoch: Epoch, _payloadPresent: boolean): number | null { + updatePreComputedCheckpoint(rootHex: RootHex, epoch: Epoch): number | null { return this.checkpointStateCache.updatePreComputedCheckpoint(rootHex, epoch); } diff --git a/packages/beacon-node/src/chain/regen/regen.ts b/packages/beacon-node/src/chain/regen/regen.ts index e8a0fe213764..037ef023bfaa 100644 --- a/packages/beacon-node/src/chain/regen/regen.ts +++ b/packages/beacon-node/src/chain/regen/regen.ts @@ -332,11 +332,6 @@ async function processSlotsByCheckpoint( * emitting "checkpoint" events after every epoch processed. * * Stops processing after no more full epochs can be processed. - * - * Output state variant: - * - Post-Gloas: If slots are processed, returns block state (payloadPresent=false). - * If no slots processed, returns preState as-is (preserves variant). - * - Pre-Gloas: Always payloadPresent=true (no block/payload distinction). */ export async function processSlotsToNearestCheckpoint( modules: { @@ -380,9 +375,6 @@ export async function processSlotsToNearestCheckpoint( // This may becomes the "official" checkpoint state if the 1st block of epoch is skipped const checkpointState = postState; const cp = getCheckpointFromState(checkpointState); - // processSlots() only does epoch transitions, never processes payloads - // Pre-Gloas: payloadPresent is always true (execution payload embedded in block) - // Post-Gloas: result is a block state (payloadPresent=false) checkpointStateCache.add(cp, checkpointState); // consumers should not mutate state ever emitter?.emit(ChainEvent.checkpoint, cp, checkpointState); diff --git a/packages/beacon-node/src/chain/stateCache/persistentCheckpointsCache.ts b/packages/beacon-node/src/chain/stateCache/persistentCheckpointsCache.ts index 8e01dff5d648..4c8c7586d1a9 100644 --- a/packages/beacon-node/src/chain/stateCache/persistentCheckpointsCache.ts +++ b/packages/beacon-node/src/chain/stateCache/persistentCheckpointsCache.ts @@ -9,7 +9,7 @@ import {IClock} from "../../util/clock.js"; import {serializeState} from "../serializeState.js"; import {CPStateDatastore, DatastoreKey} from "./datastore/index.js"; import {MapTracker} from "./mapMetrics.js"; -import {BlockStateCache, CacheItemType, CheckpointHex, CheckpointHexPayload, CheckpointStateCache} from "./types.js"; +import {BlockStateCache, CacheItemType, CheckpointHex, CheckpointStateCache} from "./types.js"; export type PersistentCheckpointStateCacheOpts = { /** Keep max n state epochs in memory, persist the rest to disk */ @@ -843,19 +843,6 @@ export function toCheckpointHex(checkpoint: phase0.Checkpoint): CheckpointHex { }; } -/** TODO GLOAS: remove after rolling back regen dual-state changes */ -export function fcCheckpointToHexPayload(checkpoint: { - epoch: Epoch; - rootHex: RootHex; - payloadStatus?: number; -}): CheckpointHexPayload { - return { - epoch: checkpoint.epoch, - rootHex: checkpoint.rootHex, - payloadPresent: true, - }; -} - export function toCheckpointKey(cp: CheckpointHex): string { return `${cp.rootHex}:${cp.epoch}`; } diff --git a/packages/beacon-node/src/chain/stateCache/types.ts b/packages/beacon-node/src/chain/stateCache/types.ts index 062c3557ae66..1cd1956e2ef0 100644 --- a/packages/beacon-node/src/chain/stateCache/types.ts +++ b/packages/beacon-node/src/chain/stateCache/types.ts @@ -4,9 +4,6 @@ import {Epoch, RootHex, phase0} from "@lodestar/types"; export type CheckpointHex = {epoch: Epoch; rootHex: RootHex}; -/** TODO GLOAS: payloadPresent is ignored — remove after rolling back regen dual-state changes */ -export type CheckpointHexPayload = {epoch: Epoch; rootHex: RootHex; payloadPresent: boolean}; - /** * Lodestar currently keeps two state caches around. * diff --git a/packages/beacon-node/test/utils/node/simTest.ts b/packages/beacon-node/test/utils/node/simTest.ts index c2b11d9538c8..1c230c1e3fc8 100644 --- a/packages/beacon-node/test/utils/node/simTest.ts +++ b/packages/beacon-node/test/utils/node/simTest.ts @@ -68,11 +68,9 @@ export function simTestInfoTracker(bn: BeaconNode, logger: Logger): () => void { if (checkpoint.epoch <= lastSeenEpoch) return; lastSeenEpoch = checkpoint.epoch; - // Pre-Gloas: payloadPresent is always true (execution payload embedded in block) const checkpointState = bn.chain.regen.getCheckpointStateSync({ - ...checkpoint, + epoch: checkpoint.epoch, rootHex: toRootHex(checkpoint.root), - payloadPresent: true, }); if (checkpointState == null) { throw Error(`Checkpoint state not found for epoch ${checkpoint.epoch} root ${toRootHex(checkpoint.root)}`); From aaa16f643ce2b37fd61865374f61923098c9582a Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:29:12 -0700 Subject: [PATCH 16/39] refactor: revert dual-state from block import, block production, and metrics --- .../src/api/impl/validator/index.ts | 6 ++- packages/beacon-node/src/chain/chain.ts | 31 +++------------ .../chain/produceBlock/computeNewStateRoot.ts | 39 +------------------ .../chain/produceBlock/produceBlockBody.ts | 6 --- .../state-transition/src/stateTransition.ts | 1 - 5 files changed, 11 insertions(+), 72 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 1ea746e169c4..607479db091f 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1641,7 +1641,7 @@ export function getValidatorApi( throw Error("Cached block production result is not full block"); } - const {executionPayload, executionRequests, payloadEnvelopeStateRoot} = produceResult as ProduceFullGloas; + const {executionPayload, executionRequests} = produceResult as ProduceFullGloas; const envelope: gloas.ExecutionPayloadEnvelope = { payload: executionPayload, @@ -1649,7 +1649,9 @@ export function getValidatorApi( builderIndex: BUILDER_INDEX_SELF_BUILD, beaconBlockRoot, slot, - stateRoot: payloadEnvelopeStateRoot, + // TODO GLOAS: stateRoot is no longer computed during block production. + // This field will be removed when we implement defer payload processing + stateRoot: ZERO_HASH, }; logger.info("Produced execution payload envelope", { diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 1efa05aa20b0..2ee877c9d7f3 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -5,7 +5,6 @@ import {BeaconConfig} from "@lodestar/config"; import {CheckpointWithPayloadStatus, IForkChoice, ProtoBlock, UpdateHeadOpt} from "@lodestar/fork-choice"; import {LoggerNode} from "@lodestar/logger/node"; import { - BUILDER_INDEX_SELF_BUILD, EFFECTIVE_BALANCE_INCREMENT, type ForkPostFulu, type ForkPostGloas, @@ -24,7 +23,6 @@ import { getEffectiveBalancesFromStateBytes, isStatePostAltair, isStatePostElectra, - isStatePostGloas, } from "@lodestar/state-transition"; import { BeaconBlock, @@ -93,8 +91,8 @@ import { } from "./opPools/index.js"; import {IChainOptions} from "./options.js"; import {PrepareNextSlotScheduler} from "./prepareNextSlot.js"; -import {computeNewStateRoot, computePayloadEnvelopeStateRoot} from "./produceBlock/computeNewStateRoot.js"; -import {AssembledBlockType, BlockType, ProduceFullGloas, ProduceResult} from "./produceBlock/index.js"; +import {computeNewStateRoot} from "./produceBlock/computeNewStateRoot.js"; +import {AssembledBlockType, BlockType, ProduceResult} from "./produceBlock/index.js"; import {BlockAttributes, produceBlockBody, produceCommonBlockBody} from "./produceBlock/produceBlockBody.js"; import {QueuedStateRegenerator, RegenCaller} from "./regen/index.js"; import {ReprocessController} from "./reprocess.js"; @@ -1062,7 +1060,7 @@ export class BeaconChain implements IBeaconChain { body, } as AssembledBlockType; - const {newStateRoot, proposerReward, postBlockState} = computeNewStateRoot(this.metrics, state, block); + const {newStateRoot, proposerReward} = computeNewStateRoot(this.metrics, state, block); block.stateRoot = newStateRoot; const blockRoot = produceResult.type === BlockType.Full @@ -1071,26 +1069,9 @@ export class BeaconChain implements IBeaconChain { const blockRootHex = toRootHex(blockRoot); const fork = this.config.getForkName(slot); - if (isForkPostGloas(fork)) { - // TODO GLOAS: we should retire BlockType post-gloas, may need a new enum for self vs non-self built - if (produceResult.type !== BlockType.Full) { - throw Error(`Unexpected block type=${produceResult.type} for post-gloas fork=${fork}`); - } - - const gloasResult = produceResult as ProduceFullGloas; - const envelope: gloas.ExecutionPayloadEnvelope = { - payload: gloasResult.executionPayload, - executionRequests: gloasResult.executionRequests, - builderIndex: BUILDER_INDEX_SELF_BUILD, - beaconBlockRoot: blockRoot, - slot, - stateRoot: ZERO_HASH, - }; - if (!isStatePostGloas(postBlockState)) { - throw Error(`Expected gloas+ post-state for execution payload envelope, got fork=${postBlockState.forkName}`); - } - const payloadEnvelopeStateRoot = computePayloadEnvelopeStateRoot(this.metrics, postBlockState, envelope); - gloasResult.payloadEnvelopeStateRoot = payloadEnvelopeStateRoot; + // TODO GLOAS: we should retire BlockType post-gloas, may need a new enum for self vs non-self built + if (isForkPostGloas(fork) && produceResult.type !== BlockType.Full) { + throw Error(`Unexpected block type=${produceResult.type} for post-gloas fork=${fork}`); } // Track the produced block for consensus broadcast validations, later validation, etc. diff --git a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts index 2c5df920a6a6..255111a013f9 100644 --- a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts +++ b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts @@ -1,12 +1,10 @@ import { DataAvailabilityStatus, ExecutionPayloadStatus, - G2_POINT_AT_INFINITY, IBeaconStateView, - IBeaconStateViewGloas, StateHashTreeRootSource, } from "@lodestar/state-transition"; -import {BeaconBlock, BlindedBeaconBlock, Gwei, Root, gloas} from "@lodestar/types"; +import {BeaconBlock, BlindedBeaconBlock, Gwei, Root} from "@lodestar/types"; import {ZERO_HASH} from "../../constants/index.js"; import {Metrics} from "../../metrics/index.js"; @@ -53,38 +51,3 @@ export function computeNewStateRoot( return {newStateRoot, proposerReward, postBlockState}; } - -/** - * Compute the state root after processing an execution payload envelope. - * Similar to `computeNewStateRoot` but for payload envelope processing. - * - */ -export function computePayloadEnvelopeStateRoot( - metrics: Metrics | null, - postBlockState: IBeaconStateViewGloas, - envelope: gloas.ExecutionPayloadEnvelope -): Root { - const signedEnvelope: gloas.SignedExecutionPayloadEnvelope = { - message: envelope, - signature: G2_POINT_AT_INFINITY, - }; - - const processEnvelopeTimer = metrics?.blockPayload.executionPayloadEnvelopeProcessingTime.startTimer(); - const postPayloadState = postBlockState.processExecutionPayloadEnvelope(signedEnvelope, { - // Signature is zero-ed (G2_POINT_AT_INFINITY), skip verification - verifySignature: false, - // State root is being computed here, the envelope doesn't have it yet - verifyStateRoot: false, - // Preserve cache in source state, since the resulting state is not added to the state cache - dontTransferCache: true, - }); - processEnvelopeTimer?.(); - - const hashTreeRootTimer = metrics?.stateHashTreeRootTime.startTimer({ - source: StateHashTreeRootSource.computePayloadEnvelopeStateRoot, - }); - const stateRoot = postPayloadState.hashTreeRoot(); - hashTreeRootTimer?.(); - - return stateRoot; -} diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 9f888729ce22..34bdd7fc83b4 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -111,12 +111,6 @@ export type ProduceFullGloas = { executionRequests: electra.ExecutionRequests; blobsBundle: BlobsBundle; cells: fulu.Cell[][]; - /** - * Cached payload envelope state root computed during block production. - * This is the state root after running `processExecutionPayloadEnvelope` on the - * post-block state, and later used to construct the `ExecutionPayloadEnvelope`. - */ - payloadEnvelopeStateRoot: Root; }; export type ProduceFullFulu = { type: BlockType.Full; diff --git a/packages/state-transition/src/stateTransition.ts b/packages/state-transition/src/stateTransition.ts index 12ffce065487..e803ef90d765 100644 --- a/packages/state-transition/src/stateTransition.ts +++ b/packages/state-transition/src/stateTransition.ts @@ -76,7 +76,6 @@ export enum StateHashTreeRootSource { prepareNextEpoch = "prepare_next_epoch", regenState = "regen_state", computeNewStateRoot = "compute_new_state_root", - computePayloadEnvelopeStateRoot = "compute_payload_envelope_state_root", } /** From bf29eba6f5d0985e55a836c669ac2c7a78fd9bcf Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:41:08 -0700 Subject: [PATCH 17/39] Clean up --- .../src/chain/blocks/importBlock.ts | 36 +++++++++---------- .../chain/blocks/importExecutionPayload.ts | 2 +- .../beacon-node/src/chain/blocks/index.ts | 2 +- .../beacon-node/src/chain/blocks/types.ts | 2 +- .../chain/produceBlock/computeNewStateRoot.ts | 10 +++--- .../beacon-node/src/chain/regen/interface.ts | 2 +- .../beacon-node/src/chain/regen/queued.ts | 2 +- .../validation/executionPayloadEnvelope.ts | 2 +- 8 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index e9dd1ee0b4f1..46d38d984e41 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -86,7 +86,7 @@ export async function importBlock( fullyVerifiedBlock: FullyVerifiedBlock, opts: ImportBlockOpts ): Promise { - const {blockInput, postBlockState, parentBlockSlot, executionStatus, dataAvailabilityStatus, indexedAttestations} = + const {blockInput, postState, parentBlockSlot, executionStatus, dataAvailabilityStatus, indexedAttestations} = fullyVerifiedBlock; const block = blockInput.getBlock(); const source = blockInput.getBlockSource(); @@ -98,7 +98,7 @@ export async function importBlock( const blockEpoch = computeEpochAtSlot(blockSlot); const prevFinalizedEpoch = this.forkChoice.getFinalizedCheckpoint().epoch; const blockDelaySec = - fullyVerifiedBlock.seenTimestampSec - computeTimeAtSlot(this.config, blockSlot, postBlockState.genesisTime); + fullyVerifiedBlock.seenTimestampSec - computeTimeAtSlot(this.config, blockSlot, postState.genesisTime); const recvToValLatency = Date.now() / 1000 - (opts.seenTimestampSec ?? Date.now() / 1000); const fork = this.config.getForkSeq(blockSlot); @@ -121,10 +121,10 @@ export async function importBlock( // 2. Import block to fork choice // Should compute checkpoint balances before forkchoice.onBlock - this.checkpointBalancesCache.processState(blockRootHex, postBlockState); + this.checkpointBalancesCache.processState(blockRootHex, postState); const blockSummary = this.forkChoice.onBlock( block.message, - postBlockState, + postState, blockDelaySec, currentSlot, fork >= ForkSeq.gloas ? ExecutionStatus.PayloadSeparated : executionStatus, @@ -133,7 +133,7 @@ export async function importBlock( // This adds the state necessary to process the next block // Some block event handlers require state being in state cache so need to do this before emitting EventType.block - this.regen.processBlockState(blockRootHex, postBlockState); + this.regen.processState(blockRootHex, postState); // For Gloas blocks, create PayloadEnvelopeInput so it's available for later payload import if (fork >= ForkSeq.gloas) { @@ -186,7 +186,7 @@ export async function importBlock( (opts.importAttestations !== AttestationImportOpt.Skip && blockEpoch >= currentEpoch - FORK_CHOICE_ATT_EPOCH_LIMIT) ) { const attestations = block.message.body.attestations; - const rootCache = new RootCache(postBlockState); + const rootCache = new RootCache(postState); const invalidAttestationErrorsByCode = new Map(); const addAttestation = fork >= ForkSeq.electra ? addAttestationPostElectra : addAttestationPreElectra; @@ -200,7 +200,7 @@ export async function importBlock( const attDataRoot = toRootHex(ssz.phase0.AttestationData.hashTreeRoot(indexedAttestation.data)); addAttestation.call( this, - postBlockState, + postState, target, attDataRoot, attestation as Attestation, @@ -315,7 +315,7 @@ export async function importBlock( if (newHead.blockRoot !== oldHead.blockRoot) { // Set head state as strong reference - this.regen.updateHeadState(newHead, postBlockState); + this.regen.updateHeadState(newHead, postState); try { this.emitter.emit(routes.events.EventType.head, { @@ -385,10 +385,10 @@ export async function importBlock( // we want to import block asap so do this in the next event loop callInNextEventLoop(() => { try { - if (isStatePostAltair(postBlockState)) { + if (isStatePostAltair(postState)) { this.lightClientServer?.onImportBlockHead( block.message as BeaconBlock, - postBlockState, + postState, parentBlockSlot ); } @@ -410,11 +410,11 @@ export async function importBlock( // and the block is weak and can potentially be reorged out. let shouldOverrideFcu = false; - if (blockSlot >= currentSlot && isStatePostBellatrix(postBlockState) && postBlockState.isExecutionStateType) { + if (blockSlot >= currentSlot && isStatePostBellatrix(postState) && postState.isExecutionStateType) { let notOverrideFcuReason = NotReorgedReason.Unknown; const proposalSlot = blockSlot + 1; try { - const proposerIndex = postBlockState.getBeaconProposer(proposalSlot); + const proposerIndex = postState.getBeaconProposer(proposalSlot); const feeRecipient = this.beaconProposerCache.get(proposerIndex); if (feeRecipient) { @@ -494,20 +494,20 @@ export async function importBlock( } } - if (!postBlockState.isStateValidatorsNodesPopulated()) { - this.logger.verbose("After importBlock caching postState without SSZ cache", {slot: postBlockState.slot}); + if (!postState.isStateValidatorsNodesPopulated()) { + this.logger.verbose("After importBlock caching postState without SSZ cache", {slot: postState.slot}); } // Cache shufflings when crossing an epoch boundary const parentEpoch = computeEpochAtSlot(parentBlockSlot); if (parentEpoch < blockEpoch) { - this.shufflingCache.processState(postBlockState); + this.shufflingCache.processState(postState); this.logger.verbose("Processed shuffling for next epoch", {parentEpoch, blockEpoch, slot: blockSlot}); } if (blockSlot % SLOTS_PER_EPOCH === 0) { // Cache state to preserve epoch transition work - const checkpointState = postBlockState; + const checkpointState = postState; const cp = getCheckpointFromState(checkpointState); this.regen.addCheckpointState(cp, checkpointState); // consumers should not mutate state ever @@ -597,11 +597,11 @@ export async function importBlock( this.metrics?.parentBlockDistance.observe(blockSlot - parentBlockSlot); this.metrics?.proposerBalanceDeltaAny.observe(fullyVerifiedBlock.proposerBalanceDelta); this.validatorMonitor?.registerImportedBlock(block.message, fullyVerifiedBlock); - if (isStatePostAltair(fullyVerifiedBlock.postBlockState)) { + if (isStatePostAltair(fullyVerifiedBlock.postState)) { this.validatorMonitor?.registerSyncAggregateInBlock( blockEpoch, (block as altair.SignedBeaconBlock).message.body.syncAggregate, - fullyVerifiedBlock.postBlockState.currentSyncCommitteeIndexed.validatorIndices + fullyVerifiedBlock.postState.currentSyncCommitteeIndexed.validatorIndices ); } diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 46b719559f69..5ce1ef176f48 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -240,7 +240,7 @@ export async function importExecutionPayload( ); // 8. Cache payload state - this.regen.processBlockState(blockRootHex, postPayloadState); + this.regen.processState(blockRootHex, postPayloadState); if (postPayloadState.slot % SLOTS_PER_EPOCH === 0) { const {checkpoint} = postPayloadState.computeAnchorCheckpoint(); this.regen.addCheckpointState(checkpoint, postPayloadState); diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index a0b5fdfe28c1..787d283f758b 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -88,7 +88,7 @@ export async function processBlocks( const fullyVerifiedBlocks = relevantBlocks.map( (block, i): FullyVerifiedBlock => ({ blockInput: block, - postBlockState: postStates[i], + postState: postStates[i], postPayloadState: null, parentBlockSlot: parentSlots[i], executionStatus: executionStatuses[i], diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index e1858040a483..83e8a023dc10 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -90,7 +90,7 @@ export type ImportBlockOpts = { type FullyVerifiedBlockBase = { blockInput: IBlockInput; - postBlockState: IBeaconStateView; + postState: IBeaconStateView; parentBlockSlot: Slot; proposerBalanceDelta: number; dataAvailabilityStatus: DataAvailabilityStatus; diff --git a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts index 255111a013f9..37ede767a88c 100644 --- a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts +++ b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts @@ -17,11 +17,11 @@ export function computeNewStateRoot( metrics: Metrics | null, state: IBeaconStateView, block: BeaconBlock | BlindedBeaconBlock -): {newStateRoot: Root; proposerReward: Gwei; postBlockState: IBeaconStateView} { +): {newStateRoot: Root; proposerReward: Gwei; postState: IBeaconStateView} { // Set signature to zero to re-use stateTransition() function which requires the SignedBeaconBlock type const blockEmptySig = {message: block, signature: ZERO_HASH}; - const postBlockState = state.stateTransition( + const postState = state.stateTransition( blockEmptySig, { // ExecutionPayloadStatus.valid: Assume payload valid, it has been produced by a trusted EL @@ -40,14 +40,14 @@ export function computeNewStateRoot( {metrics} ); - const {attestations, syncAggregate, slashing} = postBlockState.proposerRewards; + const {attestations, syncAggregate, slashing} = postState.proposerRewards; const proposerReward = BigInt(attestations + syncAggregate + slashing); const hashTreeRootTimer = metrics?.stateHashTreeRootTime.startTimer({ source: StateHashTreeRootSource.computeNewStateRoot, }); - const newStateRoot = postBlockState.hashTreeRoot(); + const newStateRoot = postState.hashTreeRoot(); hashTreeRootTimer?.(); - return {newStateRoot, proposerReward, postBlockState}; + return {newStateRoot, proposerReward, postState}; } diff --git a/packages/beacon-node/src/chain/regen/interface.ts b/packages/beacon-node/src/chain/regen/interface.ts index fc01a73f4d7e..e2f91819de62 100644 --- a/packages/beacon-node/src/chain/regen/interface.ts +++ b/packages/beacon-node/src/chain/regen/interface.ts @@ -45,7 +45,7 @@ export interface IStateRegenerator extends IStateRegeneratorInternal { getClosestHeadState(head: ProtoBlock): IBeaconStateView | null; pruneOnCheckpoint(finalizedEpoch: Epoch, justifiedEpoch: Epoch, headStateRoot: RootHex): void; pruneOnFinalized(finalizedEpoch: Epoch): void; - processBlockState(blockRootHex: RootHex, postState: IBeaconStateView): void; + processState(blockRootHex: RootHex, postState: IBeaconStateView): void; addCheckpointState(cp: phase0.Checkpoint, item: IBeaconStateView): void; updateHeadState(newHead: ProtoBlock, maybeHeadState: IBeaconStateView): void; updatePreComputedCheckpoint(rootHex: RootHex, epoch: Epoch): number | null; diff --git a/packages/beacon-node/src/chain/regen/queued.ts b/packages/beacon-node/src/chain/regen/queued.ts index 5df5287491dd..288a26633c7d 100644 --- a/packages/beacon-node/src/chain/regen/queued.ts +++ b/packages/beacon-node/src/chain/regen/queued.ts @@ -153,7 +153,7 @@ export class QueuedStateRegenerator implements IStateRegenerator { this.blockStateCache.deleteAllBeforeEpoch(finalizedEpoch); } - processBlockState(blockRootHex: RootHex, postState: IBeaconStateView): void { + processState(blockRootHex: RootHex, postState: IBeaconStateView): void { this.blockStateCache.add(postState); this.checkpointStateCache.processState(blockRootHex, postState).catch((e) => { this.logger.debug("Error processing block state", {blockRootHex, slot: postState.slot}, e); diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index a4334ef71621..256b5f567b7a 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -107,7 +107,7 @@ async function validateExecutionPayloadEnvelope( }); } - // Get the post block state which is the pre-payload state to verify the builder's signature. + // Get the block state to verify the builder's signature. const blockState = await chain.regen .getState(block.stateRoot, RegenCaller.validateGossipPayloadEnvelope) .catch(() => { From bbb5929833026246cbd50b6aaf64f0bc3e496d11 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:50:25 -0700 Subject: [PATCH 18/39] refactor: update Gloas SSZ types for deferred payload processing (specs#5094) - Add executionRequestsRoot to ExecutionPayloadBid - Remove stateRoot from ExecutionPayloadEnvelope - Add parentExecutionRequests to BeaconBlockBody Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/types/src/gloas/sszTypes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index 47e142b4e415..fee4b3ef0347 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -148,6 +148,7 @@ export const ExecutionPayloadBid = new ContainerType( value: UintNum64, executionPayment: UintNum64, blobKzgCommitments: denebSsz.BlobKzgCommitments, + executionRequestsRoot: Root, // New in consensus-specs#5094 }, {typeName: "ExecutionPayloadBid", jsonCase: "eth2"} ); @@ -167,7 +168,6 @@ export const ExecutionPayloadEnvelope = new ContainerType( builderIndex: BuilderIndex, beaconBlockRoot: Root, slot: Slot, - stateRoot: Root, }, {typeName: "ExecutionPayloadEnvelope", jsonCase: "eth2"} ); @@ -197,6 +197,7 @@ export const BeaconBlockBody = new ContainerType( // executionRequests: ExecutionRequests, // Removed in GLOAS:EIP7732 signedExecutionPayloadBid: SignedExecutionPayloadBid, // New in GLOAS:EIP7732 payloadAttestations: new ListCompositeType(PayloadAttestation, MAX_PAYLOAD_ATTESTATIONS), // New in GLOAS:EIP7732 + parentExecutionRequests: electraSsz.ExecutionRequests, // New in consensus-specs#5094 }, {typeName: "BeaconBlockBody", jsonCase: "eth2", cachePermanentRootStruct: true} ); From 644d7f34419c1462b8d50a81d10e0e0349a50769 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:51:41 -0700 Subject: [PATCH 19/39] feat: add processParentExecutionPayload and remove withdrawal early return New function implements deferred parent execution payload processing (consensus-specs#5094). Moves execution requests, builder payment, and availability updates from envelope processing to block processing. Removes the isParentBlockFull early return in processWithdrawals since processParentExecutionPayload now runs before withdrawals. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../block/processParentExecutionPayload.ts | 108 ++++++++++++++++++ .../src/block/processWithdrawals.ts | 13 +-- 2 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 packages/state-transition/src/block/processParentExecutionPayload.ts diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts new file mode 100644 index 000000000000..30149c8fec14 --- /dev/null +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -0,0 +1,108 @@ +import {ForkPostGloas, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; +import {BeaconBlock, ssz} from "@lodestar/types"; +import {byteArrayEquals, toRootHex} from "@lodestar/utils"; +import {CachedBeaconStateGloas} from "../types.js"; +import {computeEpochAtSlot} from "../util/epoch.js"; +import {processConsolidationRequest} from "./processConsolidationRequest.js"; +import {getPendingValidatorPubkeys, processDepositRequest} from "./processDepositRequest.js"; +import {processWithdrawalRequest} from "./processWithdrawalRequest.js"; + +/** + * Process parent execution payload effects as first step of processBlock. + * + * Spec: consensus-specs#5094 + * https://github.com/ethereum/consensus-specs/blob/26ed32e/specs/gloas/beacon-chain.md + */ +export function processParentExecutionPayload( + state: CachedBeaconStateGloas, + block: BeaconBlock +): void { + const bid = block.body.signedExecutionPayloadBid.message; + const parentBid = state.latestExecutionPayloadBid; + const requests = block.body.parentExecutionRequests; + + // True if this block built on the parent's full payload + const isParentFull = byteArrayEquals(bid.parentBlockHash, parentBid.blockHash); + + if (!isParentFull) { + // Parent was EMPTY -- no execution requests expected + assertEmptyExecutionRequests(requests); + return; + } + + // Parent was FULL -- verify the bid commitment and apply the payload + const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(requests); + if (!byteArrayEquals(requestsRoot, parentBid.executionRequestsRoot)) { + throw new Error( + `Parent execution requests root mismatch actual=${toRootHex(requestsRoot)} expected=${toRootHex(parentBid.executionRequestsRoot)}` + ); + } + + applyParentExecutionPayload(state, parentBid, requests); +} + +/** + * Apply parent execution payload effects to state. + * + * Spec: apply_parent_execution_payload + */ +function applyParentExecutionPayload( + state: CachedBeaconStateGloas, + parentBid: {slot: number; blockHash: Uint8Array; builderIndex: number}, + requests: {deposits: unknown[]; withdrawals: unknown[]; consolidations: unknown[]} +): void { + const fork = state.config.getForkSeq(state.slot); + const parentSlot = parentBid.slot; + const parentEpoch = computeEpochAtSlot(parentSlot); + const currentEpoch = computeEpochAtSlot(state.slot); + + // Process execution requests from parent's payload + // Execution requests are processed at state.slot (child's slot), not parent's slot + if (requests.deposits.length > 0) { + const pendingValidatorPubkeys = getPendingValidatorPubkeys(state.config, state); + for (const deposit of requests.deposits) { + processDepositRequest(fork, state, deposit, pendingValidatorPubkeys); + } + } + + for (const withdrawal of requests.withdrawals) { + processWithdrawalRequest(fork, state, withdrawal); + } + + for (const consolidation of requests.consolidations) { + processConsolidationRequest(state, consolidation); + } + + // Queue the builder payment + let paymentIndex: number | null; + if (parentEpoch === currentEpoch) { + paymentIndex = SLOTS_PER_EPOCH + (parentSlot % SLOTS_PER_EPOCH); + } else if (parentEpoch === currentEpoch - 1) { + paymentIndex = parentSlot % SLOTS_PER_EPOCH; + } else { + // Parent is older than previous epoch — payment already settled/evicted + paymentIndex = null; + } + + if (paymentIndex !== null) { + const payment = state.builderPendingPayments.get(paymentIndex).clone(); + if (payment.withdrawal.amount > 0) { + state.builderPendingWithdrawals.push(payment.withdrawal); + } + state.builderPendingPayments.set(paymentIndex, ssz.gloas.BuilderPendingPayment.defaultViewDU()); + } + + // Update parent payload availability and latest block hash + state.executionPayloadAvailability.set(parentSlot % SLOTS_PER_HISTORICAL_ROOT, true); + state.latestBlockHash = parentBid.blockHash; +} + +function assertEmptyExecutionRequests(requests: { + deposits: unknown[]; + withdrawals: unknown[]; + consolidations: unknown[]; +}): void { + if (requests.deposits.length !== 0 || requests.withdrawals.length !== 0 || requests.consolidations.length !== 0) { + throw new Error("Parent execution requests must be empty when parent block is EMPTY"); + } +} diff --git a/packages/state-transition/src/block/processWithdrawals.ts b/packages/state-transition/src/block/processWithdrawals.ts index e2b54f5e183a..ba731b51d141 100644 --- a/packages/state-transition/src/block/processWithdrawals.ts +++ b/packages/state-transition/src/block/processWithdrawals.ts @@ -11,12 +11,7 @@ import { import {BuilderIndex, ValidatorIndex, capella, ssz} from "@lodestar/types"; import {byteArrayEquals, toRootHex} from "@lodestar/utils"; import {CachedBeaconStateCapella, CachedBeaconStateElectra, CachedBeaconStateGloas} from "../types.js"; -import { - convertBuilderIndexToValidatorIndex, - convertValidatorIndexToBuilderIndex, - isBuilderIndex, - isParentBlockFull, -} from "../util/gloas.js"; +import {convertBuilderIndexToValidatorIndex, convertValidatorIndexToBuilderIndex, isBuilderIndex} from "../util/gloas.js"; import { decreaseBalance, getMaxEffectiveBalance, @@ -31,10 +26,8 @@ export function processWithdrawals( state: CachedBeaconStateCapella | CachedBeaconStateElectra | CachedBeaconStateGloas, payload?: capella.FullOrBlindedExecutionPayload ): void { - // Return early if the parent block is empty - if (fork >= ForkSeq.gloas && !isParentBlockFull(state as CachedBeaconStateGloas)) { - return; - } + // After consensus-specs#5094, processParentExecutionPayload has already handled parent effects + // before processWithdrawals is called, so no early return needed for Gloas. // processedBuilderWithdrawalsCount is withdrawals coming from builder payment since gloas (EIP-7732) // processedPartialWithdrawalsCount is withdrawals coming from EL since electra (EIP-7002) From 68457ea2ccd7e5d4e22686f609f1a4963ce4bdc3 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:52:27 -0700 Subject: [PATCH 20/39] refactor: add processParentExecutionPayload as first step in processBlock New ordering for Gloas blocks: 1. processParentExecutionPayload (NEW - before header) 2. processBlockHeader 3. processWithdrawals (no longer has empty-parent early return) 4. processExecutionPayloadBid 5. ... rest unchanged Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/state-transition/src/block/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/state-transition/src/block/index.ts b/packages/state-transition/src/block/index.ts index 2dc24d48bd5b..71633419e828 100644 --- a/packages/state-transition/src/block/index.ts +++ b/packages/state-transition/src/block/index.ts @@ -16,6 +16,7 @@ import {processExecutionPayload} from "./processExecutionPayload.js"; import {processExecutionPayloadBid} from "./processExecutionPayloadBid.js"; import {processExecutionPayloadEnvelope} from "./processExecutionPayloadEnvelope.js"; import {processOperations} from "./processOperations.js"; +import {processParentExecutionPayload} from "./processParentExecutionPayload.js"; import {processPayloadAttestation} from "./processPayloadAttestation.js"; import {processRandao} from "./processRandao.js"; import {processSyncAggregate} from "./processSyncCommittee.js"; @@ -33,6 +34,7 @@ export { processExecutionPayloadBid, processPayloadAttestation, processExecutionPayloadEnvelope, + processParentExecutionPayload, }; export * from "./externalData.js"; @@ -51,10 +53,16 @@ export function processBlock( ): void { const {verifySignatures = true} = opts ?? {}; + // Process parent execution payload effects first (consensus-specs#5094) + // Must run before processBlockHeader and processExecutionPayloadBid + if (fork >= ForkSeq.gloas) { + processParentExecutionPayload(state as CachedBeaconStateGloas, block as BeaconBlock); + } + processBlockHeader(state, block); if (fork >= ForkSeq.gloas) { - // After gloas, processWithdrawals does not take a payload parameter + // After consensus-specs#5094, processParentExecutionPayload has already handled parent effects processWithdrawals(fork, state as CachedBeaconStateGloas); } else if (fork >= ForkSeq.capella) { const fullOrBlindedPayload = getFullOrBlindedPayload(block); From 9a4a2c9f288f45cfba42faec769116b62092e453 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:53:35 -0700 Subject: [PATCH 21/39] refactor: transform processExecutionPayloadEnvelope to pure verification Envelope verification no longer mutates state. All state effects (execution requests, builder payment, availability, latestBlockHash) have moved to processParentExecutionPayload in the next block. Changes: - Remove state cloning, execution request processing, builder payment, availability/latestBlockHash updates, and state root verification - Add executionRequestsRoot check against bid commitment - Return void instead of post-state - Remove verifyStateRoot and dontTransferCache options - Rename stateView method to verifyExecutionPayloadEnvelope Co-Authored-By: Claude Opus 4.6 (1M context) --- .../block/processExecutionPayloadEnvelope.ts | 96 ++++++------------- .../src/stateView/beaconStateView.ts | 13 +-- .../src/stateView/interface.ts | 4 +- 3 files changed, 36 insertions(+), 77 deletions(-) diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index c7f858a908d0..b78e90d39071 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -1,4 +1,3 @@ -import {SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; import {gloas, ssz} from "@lodestar/types"; import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; import {getExecutionPayloadEnvelopeSignatureSet} from "../signatureSets/executionPayloadEnvelope.js"; @@ -6,81 +5,32 @@ import {BeaconStateView} from "../stateView/beaconStateView.js"; import {CachedBeaconStateGloas} from "../types.js"; import {computeTimeAtSlot} from "../util/index.js"; import {verifySignatureSet} from "../util/signatureSets.js"; -import {processConsolidationRequest} from "./processConsolidationRequest.js"; -import {getPendingValidatorPubkeys, processDepositRequest} from "./processDepositRequest.js"; -import {processWithdrawalRequest} from "./processWithdrawalRequest.js"; export type ProcessExecutionPayloadEnvelopeOpts = { verifySignature?: boolean; - verifyStateRoot?: boolean; - dontTransferCache?: boolean; }; -// Unlike other block processing functions which mutate state in-place, this function -// clones the state and returns the post-state, similar to stateTransition(). -// This function does not call execution engine to verify payload. Need to call it from other place. +/** + * Pure verification of execution payload envelope — no state mutation. + * Payload processing is deferred to the next beacon block via processParentExecutionPayload. + * + * This function does not call the execution engine. That must be done separately. + * + * Spec: consensus-specs#5094 verify_execution_payload_envelope + */ export function processExecutionPayloadEnvelope( state: CachedBeaconStateGloas, signedEnvelope: gloas.SignedExecutionPayloadEnvelope, opts?: ProcessExecutionPayloadEnvelopeOpts -): CachedBeaconStateGloas { - const {verifySignature = true, verifyStateRoot = true} = opts ?? {}; +): void { + const {verifySignature = true} = opts ?? {}; const envelope = signedEnvelope.message; - const payload = envelope.payload; - const fork = state.config.getForkSeq(envelope.slot); if (verifySignature && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); } - // .clone() before mutating state, similar to stateTransition() - const postState = state.clone(opts?.dontTransferCache) as CachedBeaconStateGloas; - - validateExecutionPayloadEnvelope(postState, envelope); - - const requests = envelope.executionRequests; - - if (requests.deposits.length > 0) { - // Build cache of pending validator pubkeys once, shared across all deposit requests - const pendingValidatorPubkeys = getPendingValidatorPubkeys(postState.config, postState); - - for (const deposit of requests.deposits) { - processDepositRequest(fork, postState, deposit, pendingValidatorPubkeys); - } - } - - for (const withdrawal of requests.withdrawals) { - processWithdrawalRequest(fork, postState, withdrawal); - } - - for (const consolidation of requests.consolidations) { - processConsolidationRequest(postState, consolidation); - } - - // Queue the builder payment - const paymentIndex = SLOTS_PER_EPOCH + (postState.slot % SLOTS_PER_EPOCH); - const payment = postState.builderPendingPayments.get(paymentIndex).clone(); - const amount = payment.withdrawal.amount; - - if (amount > 0) { - postState.builderPendingWithdrawals.push(payment.withdrawal); - } - - postState.builderPendingPayments.set(paymentIndex, ssz.gloas.BuilderPendingPayment.defaultViewDU()); - - // Cache the execution payload hash - postState.executionPayloadAvailability.set(postState.slot % SLOTS_PER_HISTORICAL_ROOT, true); - postState.latestBlockHash = payload.blockHash; - - postState.commit(); - - if (verifyStateRoot && !byteArrayEquals(envelope.stateRoot, postState.hashTreeRoot())) { - throw new Error( - `Envelope's state root does not match state envelope=${toRootHex(envelope.stateRoot)} state=${toRootHex(postState.hashTreeRoot())}` - ); - } - - return postState; + validateExecutionPayloadEnvelope(state, envelope); } function validateExecutionPayloadEnvelope( @@ -90,15 +40,21 @@ function validateExecutionPayloadEnvelope( const payload = envelope.payload; // Cache latest block header state root + // Note: we read but do NOT mutate state — we compute the header root on a copy + let headerRoot: Uint8Array; if (byteArrayEquals(state.latestBlockHeader.stateRoot, ssz.Root.defaultValue())) { - const previousStateRoot = state.hashTreeRoot(); - state.latestBlockHeader.stateRoot = previousStateRoot; + // Compute what the header root would be with the state root filled in + const header = ssz.phase0.BeaconBlockHeader.toViewDU(state.latestBlockHeader.toValue()); + header.stateRoot = state.hashTreeRoot(); + headerRoot = header.hashTreeRoot(); + } else { + headerRoot = state.latestBlockHeader.hashTreeRoot(); } // Verify consistency with the beacon block - if (!byteArrayEquals(envelope.beaconBlockRoot, state.latestBlockHeader.hashTreeRoot())) { + if (!byteArrayEquals(envelope.beaconBlockRoot, headerRoot)) { throw new Error( - `Envelope's block is not the latest block header envelope=${toRootHex(envelope.beaconBlockRoot)} latestBlockHeader=${toRootHex(state.latestBlockHeader.hashTreeRoot())}` + `Envelope's block is not the latest block header envelope=${toRootHex(envelope.beaconBlockRoot)} latestBlockHeader=${toRootHex(headerRoot)}` ); } @@ -143,6 +99,14 @@ function validateExecutionPayloadEnvelope( ); } + // Verify execution_requests_root matches bid commitment (consensus-specs#5094) + const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); + if (!byteArrayEquals(requestsRoot, committedBid.executionRequestsRoot)) { + throw new Error( + `Execution requests root mismatch envelope=${toRootHex(requestsRoot)} committedBid=${toRootHex(committedBid.executionRequestsRoot)}` + ); + } + // Verify consistency of the parent hash with respect to the previous execution payload if (!byteArrayEquals(payload.parentHash, state.latestBlockHash)) { throw new Error( @@ -157,7 +121,7 @@ function validateExecutionPayloadEnvelope( ); } - // Skipped: Verify the execution payload is valid + // Execution engine verification (verify_and_notify_new_payload) is done externally } function verifyExecutionPayloadEnvelopeSignature( diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index e491bcb52c8c..02f03080a08b 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -794,19 +794,14 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { return new BeaconStateView(newState); } - processExecutionPayloadEnvelope( + verifyExecutionPayloadEnvelope( signedEnvelope: gloas.SignedExecutionPayloadEnvelope, opts?: ProcessExecutionPayloadEnvelopeOpts - ): BeaconStateView { + ): void { const fork = this.config.getForkName(this.cachedState.slot); if (!isForkPostGloas(fork)) { - throw Error(`processExecutionPayloadEnvelope is only available for gloas+ forks, got fork=${fork}`); + throw Error(`verifyExecutionPayloadEnvelope is only available for gloas+ forks, got fork=${fork}`); } - const postPayloadState = processExecutionPayloadEnvelope( - this.cachedState as CachedBeaconStateGloas, - signedEnvelope, - opts - ); - return new BeaconStateView(postPayloadState); + processExecutionPayloadEnvelope(this.cachedState as CachedBeaconStateGloas, signedEnvelope, opts); } } diff --git a/packages/state-transition/src/stateView/interface.ts b/packages/state-transition/src/stateView/interface.ts index 8ae51b8702f2..388d1d0850cd 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -250,10 +250,10 @@ export interface IBeaconStateViewGloas extends IBeaconStateViewFulu { getBuilder(index: BuilderIndex): gloas.Builder; canBuilderCoverBid(builderIndex: BuilderIndex, bidAmount: number): boolean; getIndexInPayloadTimelinessCommittee(validatorIndex: ValidatorIndex, slot: Slot): number; - processExecutionPayloadEnvelope( + verifyExecutionPayloadEnvelope( signedEnvelope: gloas.SignedExecutionPayloadEnvelope, opts?: ProcessExecutionPayloadEnvelopeOpts - ): IBeaconStateView; + ): void; } /** From 7b851dd010df37b9b3c3f71698b0aa8a6bb53ccc Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:54:24 -0700 Subject: [PATCH 22/39] refactor: remove executionPayloadStateRoot from fork choice onExecutionPayload With deferred payload processing, the envelope no longer produces a separate post-state. The FULL variant node shares the same stateRoot as the PENDING node. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fork-choice/src/forkChoice/forkChoice.ts | 2 -- packages/fork-choice/src/forkChoice/interface.ts | 2 -- packages/fork-choice/src/protoArray/protoArray.ts | 4 ++-- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index ac223f321db7..ab6782bc7de7 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -980,7 +980,6 @@ export class ForkChoice implements IForkChoice { blockRoot: RootHex, executionPayloadBlockHash: RootHex, executionPayloadNumber: number, - executionPayloadStateRoot: RootHex, executionStatus: PayloadExecutionStatus ): void { this.protoArray.onExecutionPayload( @@ -988,7 +987,6 @@ export class ForkChoice implements IForkChoice { this.fcStore.currentSlot, executionPayloadBlockHash, executionPayloadNumber, - executionPayloadStateRoot, this.proposerBoostRoot, executionStatus ); diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 6b258518dd17..f2ab2e0d0bd8 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -198,13 +198,11 @@ export interface IForkChoice { * @param blockRoot - The beacon block root for which the payload arrived * @param executionPayloadBlockHash - The block hash of the execution payload * @param executionPayloadNumber - The block number of the execution payload - * @param executionPayloadStateRoot - The execution payload state root ie. the root of post-state after processExecutionPayloadEnvelope() */ onExecutionPayload( blockRoot: RootHex, executionPayloadBlockHash: RootHex, executionPayloadNumber: number, - executionPayloadStateRoot: RootHex, executionStatus: PayloadExecutionStatus ): void; /** diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 0d98df50bb82..1225ebd47f31 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -572,7 +572,6 @@ export class ProtoArray { currentSlot: Slot, executionPayloadBlockHash: RootHex, executionPayloadNumber: number, - executionPayloadStateRoot: RootHex, proposerBoostRoot: RootHex | null, executionStatus: PayloadExecutionStatus ): void { @@ -617,6 +616,8 @@ export class ProtoArray { } // Create FULL variant as a child of PENDING (sibling to EMPTY) + // With deferred payload processing (consensus-specs#5094), FULL shares the same + // stateRoot as PENDING since envelope no longer produces a separate post-state const fullNode: ProtoNode = { ...pendingNode, parent: pendingIndex, // Points to own PENDING (same as EMPTY) @@ -628,7 +629,6 @@ export class ProtoArray { executionStatus, executionPayloadBlockHash, executionPayloadNumber, - stateRoot: executionPayloadStateRoot, }; const fullIndex = this.nodes.length; From 5fbb0bc0d3087dc3ae80e8fbecd9e471f047c85c Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:56:42 -0700 Subject: [PATCH 23/39] refactor: simplify envelope import pipeline for deferred processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - importExecutionPayload now does pure verification only — no state cloning, no post-payload state computation, no state root check, no regen.processState(), no checkpoint caching - Remove stateRoot from executionPayload and executionPayloadGossip events - Remove stateRoot from envelope construction in validator API - Remove stateRoot from fork choice onExecutionPayload call - Envelope verification runs via verifyExecutionPayloadEnvelope (void) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/api/src/beacon/routes/events.ts | 4 - .../src/api/impl/beacon/blocks/index.ts | 4 +- .../src/api/impl/validator/index.ts | 3 - .../chain/blocks/importExecutionPayload.ts | 118 ++++++------------ .../src/network/processor/gossipHandlers.ts | 1 - 5 files changed, 40 insertions(+), 90 deletions(-) diff --git a/packages/api/src/beacon/routes/events.ts b/packages/api/src/beacon/routes/events.ts index 9c2a28458684..e631f0ced8bd 100644 --- a/packages/api/src/beacon/routes/events.ts +++ b/packages/api/src/beacon/routes/events.ts @@ -186,7 +186,6 @@ export type EventData = { builderIndex: BuilderIndex; blockHash: RootHex; blockRoot: RootHex; - stateRoot: RootHex; executionOptimistic: boolean; }; [EventType.executionPayloadGossip]: { @@ -194,7 +193,6 @@ export type EventData = { builderIndex: BuilderIndex; blockHash: RootHex; blockRoot: RootHex; - stateRoot: RootHex; }; [EventType.executionPayloadAvailable]: { slot: Slot; @@ -376,7 +374,6 @@ export function getTypeByEvent(config: ChainForkConfig): {[K in EventType]: Type builderIndex: ssz.BuilderIndex, blockHash: stringType, blockRoot: stringType, - stateRoot: stringType, executionOptimistic: ssz.Boolean, }, {jsonCase: "eth2"} @@ -387,7 +384,6 @@ export function getTypeByEvent(config: ChainForkConfig): {[K in EventType]: Type builderIndex: ssz.BuilderIndex, blockHash: stringType, blockRoot: stringType, - stateRoot: stringType, }, {jsonCase: "eth2"} ), diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index fa8e523d3f1c..645b3c64387c 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -655,7 +655,7 @@ export function getBeaconBlockApi({ const fork = config.getForkName(slot); const blockRootHex = toRootHex(envelope.beaconBlockRoot); const blockHashHex = toRootHex(envelope.payload.blockHash); - const stateRootHex = toRootHex(envelope.stateRoot); + // stateRoot removed from envelope in consensus-specs#5094 if (!isForkPostGloas(fork)) { throw new ApiError(400, `publishExecutionPayloadEnvelope not supported for pre-gloas fork=${fork}`); @@ -740,7 +740,6 @@ export function getBeaconBlockApi({ slot, blockRoot: blockRootHex, blockHash: blockHashHex, - stateRoot: stateRootHex, builderIndex: envelope.builderIndex, isSelfBuild, dataColumns: dataColumnSidecars.length, @@ -768,7 +767,6 @@ export function getBeaconBlockApi({ builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, - stateRoot: stateRootHex, }); const sentPeersArr = await publishPromise; diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 607479db091f..9d4cd0d3d53d 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1649,9 +1649,6 @@ export function getValidatorApi( builderIndex: BUILDER_INDEX_SELF_BUILD, beaconBlockRoot, slot, - // TODO GLOAS: stateRoot is no longer computed during block production. - // This field will be removed when we implement defer payload processing - stateRoot: ZERO_HASH, }; logger.info("Produced execution payload envelope", { diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 5ce1ef176f48..c7edeb6f41a4 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,8 +1,7 @@ import {routes} from "@lodestar/api"; import {ExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice"; -import {SLOTS_PER_EPOCH} from "@lodestar/params"; import {getExecutionPayloadEnvelopeSignatureSet, isStatePostGloas} from "@lodestar/state-transition"; -import {byteArrayEquals, fromHex, toRootHex} from "@lodestar/utils"; +import {fromHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; @@ -68,18 +67,20 @@ function toForkChoiceExecutionStatus(status: ExecutionPayloadStatus): PayloadExe /** * Import an execution payload envelope after all data is available. * - * This function: - * 1. Emits `execution_payload_available` if payload is for current slot - * 2. Gets the ProtoBlock from fork choice - * 3. Applies write-queue backpressure (waitForSpace) early, before verification - * 4. Regenerates the block state - * 5. Runs EL verification (notifyNewPayload) in parallel with signature verification and processExecutionPayloadEnvelope - * 6. Persists verified payload envelope to hot DB - * 7. Updates fork choice - * 8. Caches the post-execution payload state - * 9. Records metrics for column sources - * 10. Emits `execution_payload` for recent enough payloads after successful import + * With deferred processing (consensus-specs#5094), the envelope is purely verified + * here — no state mutation. State effects are applied in the next block via + * processParentExecutionPayload. * + * Steps: + * 1. Emit `execution_payload_available` for payload attestation + * 2. Get the ProtoBlock from fork choice + * 3. Apply write-queue backpressure + * 4. Regenerate block state for envelope field validation + * 5. Run EL verification and signature verification in parallel, plus pure envelope verification + * 6. Persist verified payload envelope to hot DB + * 7. Update fork choice (no stateRoot — FULL shares PENDING's stateRoot) + * 8. Record metrics + * 9. Emit `execution_payload` event */ export async function importExecutionPayload( this: BeaconChain, @@ -92,10 +93,7 @@ export async function importExecutionPayload( const blockHashHex = payloadInput.getBlockHashHex(); const fork = this.config.getForkName(envelope.slot); - // 1. Emit `execution_payload_available` event at the start of import. At this point the payload input - // is already complete, so the payload and required data are available for payload attestation. - // This event is only about availability, not validity of the execution payload, hence we can emit - // it before getting a response from the execution client on whether the payload is valid or not. + // 1. Emit `execution_payload_available` event at the start of import if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayloadAvailable, { slot: envelope.slot, @@ -112,12 +110,10 @@ export async function importExecutionPayload( }); } - // 3. Apply backpressure from the write queue early, before doing verification work. - // The actual DB write is deferred until after verification succeeds. + // 3. Apply backpressure from the write queue early, before doing verification work await this.unfinalizedPayloadEnvelopeWrites.waitForSpace(); - // 4. Get pre-state for processExecutionPayloadEnvelope - // We need the block state (post-block, pre-payload) to process the envelope + // 4. Get block state for envelope field validation const blockState = await this.regen.getBlockSlotState( protoBlock, protoBlock.slot, @@ -132,9 +128,7 @@ export async function importExecutionPayload( } // 5. Run verification steps in parallel - // Note: No data availability check needed here - importExecutionPayload is only - // called when payloadInput.isComplete() is true, so all data is already available. - const [execResult, signatureValid, postPayloadResult] = await Promise.all([ + const [execResult, signatureValid] = await Promise.all([ this.executionEngine.notifyNewPayload( fork, envelope.payload, @@ -155,35 +149,27 @@ export async function importExecutionPayload( ); return this.bls.verifySignatureSets([signatureSet]); })(), - - // Signature verified separately above. - // State root check is done separately below with better error typing (matching block pipeline pattern). - (async () => { - try { - return { - postPayloadState: blockState.processExecutionPayloadEnvelope(signedEnvelope, { - verifySignature: false, - verifyStateRoot: false, - }), - }; - } catch (e) { - throw new PayloadError( - { - code: PayloadErrorCode.STATE_TRANSITION_ERROR, - message: (e as Error).message, - }, - `State transition error: ${(e as Error).message}` - ); - } - })(), ]); - // 5a. Check signature verification result + // 5a. Pure envelope verification (no state mutation) + try { + blockState.verifyExecutionPayloadEnvelope(signedEnvelope, {verifySignature: false}); + } catch (e) { + throw new PayloadError( + { + code: PayloadErrorCode.STATE_TRANSITION_ERROR, + message: (e as Error).message, + }, + `Envelope verification error: ${(e as Error).message}` + ); + } + + // 5b. Check signature verification result if (!signatureValid) { throw new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE}); } - // 5b. Handle EL response + // 5c. Handle EL response switch (execResult.status) { case ExecutionPayloadStatus.VALID: break; @@ -209,17 +195,7 @@ export async function importExecutionPayload( }); } - // 5c. Verify envelope state root matches post-state - const postPayloadState = postPayloadResult.postPayloadState; - const postPayloadStateRoot = postPayloadState.hashTreeRoot(); - if (!byteArrayEquals(envelope.stateRoot, postPayloadStateRoot)) { - throw new PayloadError({ - code: PayloadErrorCode.STATE_TRANSITION_ERROR, - message: `Envelope state root mismatch expected=${toRootHex(envelope.stateRoot)} actual=${toRootHex(postPayloadStateRoot)}`, - }); - } - - // 6. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) + // 6. Persist payload envelope to hot DB this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { if (!isQueueErrorAborted(e)) { this.logger.error( @@ -230,38 +206,23 @@ export async function importExecutionPayload( } }); - // 7. Update fork choice - this.forkChoice.onExecutionPayload( - blockRootHex, - blockHashHex, - envelope.payload.blockNumber, - toRootHex(postPayloadStateRoot), - toForkChoiceExecutionStatus(execResult.status) - ); - - // 8. Cache payload state - this.regen.processState(blockRootHex, postPayloadState); - if (postPayloadState.slot % SLOTS_PER_EPOCH === 0) { - const {checkpoint} = postPayloadState.computeAnchorCheckpoint(); - this.regen.addCheckpointState(checkpoint, postPayloadState); - } + // 7. Update fork choice — no separate stateRoot since envelope doesn't produce post-state + const execStatus = toForkChoiceExecutionStatus(execResult.status); + this.forkChoice.onExecutionPayload(blockRootHex, blockHashHex, envelope.payload.blockNumber, execStatus); - // 9. Record metrics for payload envelope and column sources + // 8. Record metrics for payload envelope and column sources this.metrics?.importPayload.bySource.inc({source: payloadInput.getPayloadEnvelopeSource().source}); for (const {source} of payloadInput.getSampledColumnsWithSource()) { this.metrics?.importPayload.columnsBySource.inc({source}); } - const stateRootHex = toRootHex(envelope.stateRoot); - - // 10. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads + // 9. Emit event after payload is fully verified and imported to fork choice if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayload, { slot: envelope.slot, builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, - stateRoot: stateRootHex, // TODO GLOAS: revisit once we support optimistic import executionOptimistic: false, }); @@ -272,6 +233,5 @@ export async function importExecutionPayload( builderIndex: envelope.builderIndex, blockRoot: blockRootHex, blockHash: blockHashHex, - stateRoot: stateRootHex, }); } diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 263ac747b10e..00103e4ed63a 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -1102,7 +1102,6 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand builderIndex: envelope.builderIndex, blockHash: toRootHex(envelope.payload.blockHash), blockRoot: blockRootHex, - stateRoot: toRootHex(envelope.stateRoot), }); chain.processExecutionPayload(payloadInput, {validSignature: true}).catch((e) => { From 067edb1c265c034595ecaffd5d5c05644920b0c1 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:01:04 -0700 Subject: [PATCH 24/39] feat: block production, gossip validation, and cleanup for deferred processing Block production: - Add executionRequestsRoot to ExecutionPayloadBid construction - Add parentExecutionRequests to BeaconBlockBody via getParentExecutionRequests() - Add getParentExecutionRequests() to BeaconChain (returns parent's execution requests from cached envelope, or empty if EMPTY parent) Gossip validation: - Add executionRequestsRoot check in envelope validation - Add EXECUTION_REQUESTS_ROOT_MISMATCH error code Cleanup: - Remove stateRoot from validator envelope logging - Fix spec test for void-returning processExecutionPayloadEnvelope - Update stale TODO comment in writePayloadEnvelopeInputToDb Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chain/blocks/writePayloadEnvelopeInputToDb.ts | 2 +- packages/beacon-node/src/chain/chain.ts | 15 +++++++++++++++ .../src/chain/errors/executionPayloadEnvelope.ts | 6 ++++++ .../src/chain/produceBlock/produceBlockBody.ts | 6 ++++++ .../chain/validation/executionPayloadEnvelope.ts | 14 ++++++++++++-- .../test/spec/presets/operations.test.ts | 5 ++--- packages/validator/src/services/block.ts | 3 +-- 7 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index 425bd83adca8..d5c07a563590 100644 --- a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -5,7 +5,7 @@ import {writeDataColumnsToDb} from "./writeBlockInputToDb.js"; /** * Persists payload envelope data to DB. This operation must be eventually completed if a payload is imported. * - * TODO GLOAS: Persist envelope metadata (stateRoot, executionRequests, builderIndex, etc.) without the full + * TODO GLOAS: Persist envelope metadata (executionRequests, builderIndex, etc.) without the full * execution payload body — only keep the blockHash reference. The EL already stores the payload. * See https://github.com/ChainSafe/lodestar/issues/5671 */ diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 2ee877c9d7f3..417a65fdf37a 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -39,6 +39,7 @@ import { ValidatorIndex, Wei, deneb, + electra, gloas, isBlindedBeaconBlock, phase0, @@ -870,6 +871,20 @@ export class BeaconChain implements IBeaconChain { ); } + /** + * Get execution requests from parent's payload envelope for block production. + * If parent was FULL, returns the execution requests from the cached envelope. + * If parent was EMPTY or envelope unknown, returns empty execution requests. + */ + getParentExecutionRequests(parentBlockRootHex: RootHex): electra.ExecutionRequests { + const payloadInput = this.seenPayloadEnvelopeInputCache.get(parentBlockRootHex); + if (payloadInput?.hasPayloadEnvelope()) { + return payloadInput.getPayloadEnvelope().message.executionRequests; + } + // Parent was EMPTY or we don't have the envelope — return empty requests + return ssz.electra.ExecutionRequests.defaultValue(); + } + async getExecutionPayloadEnvelope( blockSlot: Slot, blockRootHex: string diff --git a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts index cf7a0fe4a002..c8a8645d6dc5 100644 --- a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts @@ -11,6 +11,7 @@ export enum ExecutionPayloadEnvelopeErrorCode { SLOT_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_SLOT_MISMATCH", BUILDER_INDEX_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BUILDER_INDEX_MISMATCH", BLOCK_HASH_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BLOCK_HASH_MISMATCH", + EXECUTION_REQUESTS_ROOT_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_EXECUTION_REQUESTS_ROOT_MISMATCH", INVALID_SIGNATURE = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_INVALID_SIGNATURE", PAYLOAD_ENVELOPE_INPUT_MISSING = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_PAYLOAD_ENVELOPE_INPUT_MISSING", } @@ -36,6 +37,11 @@ export type ExecutionPayloadEnvelopeErrorType = envelopeBlockHash: RootHex; bidBlockHash: RootHex | null; } + | { + code: ExecutionPayloadEnvelopeErrorCode.EXECUTION_REQUESTS_ROOT_MISMATCH; + envelopeRequestsRoot: RootHex; + bidRequestsRoot: RootHex; + } | {code: ExecutionPayloadEnvelopeErrorCode.INVALID_SIGNATURE} | {code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING; blockRoot: RootHex}; diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 34bdd7fc83b4..c06346a6253d 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -47,6 +47,7 @@ import { electra, fulu, gloas, + ssz, } from "@lodestar/types"; import {Logger, fromHex, sleep, toHex, toPubkeyHex, toRootHex} from "@lodestar/utils"; import {ZERO_HASH_HEX} from "../../constants/index.js"; @@ -266,6 +267,7 @@ export async function produceBlockBody( value: 0, executionPayment: 0, blobKzgCommitments: blobsBundle.commitments, + executionRequestsRoot: ssz.electra.ExecutionRequests.hashTreeRoot(executionRequests), }; const signedBid: gloas.SignedExecutionPayloadBid = { message: bid, @@ -277,6 +279,10 @@ export async function produceBlockBody( gloasBody.signedExecutionPayloadBid = signedBid; // TODO GLOAS: Get payload attestations from pool for previous slot gloasBody.payloadAttestations = []; + // Determine parent execution requests for deferred processing (consensus-specs#5094) + // If parent was FULL: include execution requests from its envelope + // If parent was EMPTY: include empty execution requests + gloasBody.parentExecutionRequests = this.getParentExecutionRequests(parentBlockRootHex); blockBody = gloasBody as AssembledBodyType; // Store execution payload data required to construct execution payload envelope later diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 256b5f567b7a..ef58992a6bbb 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -4,8 +4,8 @@ import { getExecutionPayloadEnvelopeSignatureSet, isStatePostGloas, } from "@lodestar/state-transition"; -import {gloas} from "@lodestar/types"; -import {toRootHex} from "@lodestar/utils"; +import {gloas, ssz} from "@lodestar/types"; +import {byteArrayEquals, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadEnvelopeError, ExecutionPayloadEnvelopeErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../index.js"; import {RegenCaller} from "../regen/index.js"; @@ -98,6 +98,16 @@ async function validateExecutionPayloadEnvelope( }); } + // [REJECT] `hash_tree_root(envelope.execution_requests) == bid.execution_requests_root` + const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); + if (!byteArrayEquals(requestsRoot, payloadInput.getBid().executionRequestsRoot)) { + throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { + code: ExecutionPayloadEnvelopeErrorCode.EXECUTION_REQUESTS_ROOT_MISMATCH, + envelopeRequestsRoot: toRootHex(requestsRoot), + bidRequestsRoot: toRootHex(payloadInput.getBid().executionRequestsRoot), + }); + } + // [REJECT] `payload.block_hash == bid.block_hash` if (toRootHex(payload.blockHash) !== payloadInput.getBlockHashHex()) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index 48fd66e28c50..26fd54409134 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -78,10 +78,10 @@ const operationFns: Record> = ): CachedBeaconStateAllForks | void => { const fork = state.config.getForkSeq(state.slot); if (fork >= ForkSeq.gloas) { - return blockFns.processExecutionPayloadEnvelope(state as CachedBeaconStateGloas, testCase.signed_envelope, { + blockFns.processExecutionPayloadEnvelope(state as CachedBeaconStateGloas, testCase.signed_envelope, { verifySignature: true, - verifyStateRoot: true, }); + return; } blockFns.processExecutionPayload(fork, state as CachedBeaconStateBellatrix, testCase.body, { executionPayloadStatus: testCase.execution.execution_valid @@ -144,7 +144,6 @@ const operations: TestRunnerFn = (fork, const cachedState = createCachedBeaconStateTest(state, getConfig(fork, epoch)); const postState = operationFn(cachedState, testcase); - // processExecutionPayloadEnvelope returns the postState, other operations mutate the state in-place and return void if (postState !== undefined) { postState.commit(); return postState; diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index a3f198273d5e..ebe04f09971a 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -241,9 +241,8 @@ export class BlockProposingService { beaconBlockRoot, }); const envelope = envelopeRes.value(); - const stateRootHex = toRootHex(envelope.stateRoot); - this.logger.debug("Retrieved execution payload envelope", {...debugLogCtx, stateRoot: stateRootHex}); + this.logger.debug("Retrieved execution payload envelope", debugLogCtx); // Step 4: Sign and publish the envelope const signedEnvelope = await this.validatorStore.signExecutionPayloadEnvelope(pubkey, envelope, slot, this.logger); From a3385bd6fb0919fdd024801993c50ccbb0363a3a Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:01:14 -0700 Subject: [PATCH 25/39] fix: type errors in processParentExecutionPayload and produceBlockBody - Use electra.ExecutionRequests type instead of unknown[] - Fix parentBlockRootHex -> parentBlock.blockRoot variable name Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/chain/produceBlock/produceBlockBody.ts | 2 +- .../src/block/processParentExecutionPayload.ts | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index c06346a6253d..caf0be353987 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -282,7 +282,7 @@ export async function produceBlockBody( // Determine parent execution requests for deferred processing (consensus-specs#5094) // If parent was FULL: include execution requests from its envelope // If parent was EMPTY: include empty execution requests - gloasBody.parentExecutionRequests = this.getParentExecutionRequests(parentBlockRootHex); + gloasBody.parentExecutionRequests = this.getParentExecutionRequests(parentBlock.blockRoot); blockBody = gloasBody as AssembledBodyType; // Store execution payload data required to construct execution payload envelope later diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index 30149c8fec14..0560f75003fc 100644 --- a/packages/state-transition/src/block/processParentExecutionPayload.ts +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -1,5 +1,5 @@ import {ForkPostGloas, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {BeaconBlock, ssz} from "@lodestar/types"; +import {BeaconBlock, electra, ssz} from "@lodestar/types"; import {byteArrayEquals, toRootHex} from "@lodestar/utils"; import {CachedBeaconStateGloas} from "../types.js"; import {computeEpochAtSlot} from "../util/epoch.js"; @@ -49,7 +49,7 @@ export function processParentExecutionPayload( function applyParentExecutionPayload( state: CachedBeaconStateGloas, parentBid: {slot: number; blockHash: Uint8Array; builderIndex: number}, - requests: {deposits: unknown[]; withdrawals: unknown[]; consolidations: unknown[]} + requests: electra.ExecutionRequests ): void { const fork = state.config.getForkSeq(state.slot); const parentSlot = parentBid.slot; @@ -97,11 +97,7 @@ function applyParentExecutionPayload( state.latestBlockHash = parentBid.blockHash; } -function assertEmptyExecutionRequests(requests: { - deposits: unknown[]; - withdrawals: unknown[]; - consolidations: unknown[]; -}): void { +function assertEmptyExecutionRequests(requests: electra.ExecutionRequests): void { if (requests.deposits.length !== 0 || requests.withdrawals.length !== 0 || requests.consolidations.length !== 0) { throw new Error("Parent execution requests must be empty when parent block is EMPTY"); } From e2c96a56e881151c3600ce9a92dc53e349650808 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:26:25 -0700 Subject: [PATCH 26/39] feat: range sync envelope support for Gloas (supersedes #9155) Range sync now fetches and validates execution payload envelopes alongside blocks for Gloas forks. With deferred processing, blocks can sync optimistically without envelopes, but envelopes are fetched in parallel for fork-choice FULL/EMPTY variant accuracy. Changes: - Batch carries payloadEnvelopes across all state transitions - downloadByRange fetches envelopes via sendExecutionPayloadEnvelopesByRange - validateEnvelopesByRangeResponse verifies beaconBlockRoot matches blocks - processChainSegment accepts payloadEnvelopes parameter - SyncChainFns types updated for envelope data flow - Add INVALID_ENVELOPE_BEACON_BLOCK_ROOT error code Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chain/blocks/importExecutionPayload.ts | 7 +- packages/beacon-node/src/chain/chain.ts | 32 ++++- packages/beacon-node/src/chain/interface.ts | 6 +- packages/beacon-node/src/sync/range/batch.ts | 69 +++++++--- packages/beacon-node/src/sync/range/chain.ts | 19 ++- packages/beacon-node/src/sync/range/range.ts | 15 ++- .../src/sync/utils/downloadByRange.ts | 123 ++++++++++++++++-- .../perf/chain/verifyImportBlocks.test.ts | 2 +- .../test/unit/sync/range/batch.test.ts | 26 ++-- .../test/unit/sync/range/chain.test.ts | 8 +- .../unit/sync/range/utils/batches.test.ts | 2 +- .../sync/range/utils/peerBalancer.test.ts | 2 +- .../block/processExecutionPayloadEnvelope.ts | 21 +-- 13 files changed, 266 insertions(+), 66 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index c7edeb6f41a4..3b1c1da903f3 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -153,7 +153,12 @@ export async function importExecutionPayload( // 5a. Pure envelope verification (no state mutation) try { - blockState.verifyExecutionPayloadEnvelope(signedEnvelope, {verifySignature: false}); + // When validSignature is true, the envelope came from gossip/API where both + // signature and executionRequestsRoot were already verified — skip re-hashing + blockState.verifyExecutionPayloadEnvelope(signedEnvelope, { + verifySignature: false, + verifyExecutionRequestsRoot: !opts.validSignature, + }); } catch (e) { throw new PayloadError( { diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 417a65fdf37a..434e78c9dbd7 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -73,6 +73,7 @@ import {BlockProcessor, ImportBlockOpts} from "./blocks/index.js"; import {PayloadEnvelopeProcessor} from "./blocks/payloadEnvelopeProcessor.js"; import {ImportPayloadOpts} from "./blocks/types.js"; import {persistBlockInput} from "./blocks/writeBlockInputToDb.js"; +import {PayloadEnvelopeInputSource} from "./blocks/payloadEnvelopeInput/types.js"; import {persistPayloadEnvelopeInput} from "./blocks/writePayloadEnvelopeInputToDb.js"; import {BlsMultiThreadWorkerPool, BlsSingleThreadVerifier, IBlsVerifier} from "./bls/index.js"; import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js"; @@ -1100,8 +1101,35 @@ export class BeaconChain implements IBeaconChain { return this.blockProcessor.processBlocksJob([block], opts); } - async processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise { - return this.blockProcessor.processBlocksJob(blocks, opts); + async processChainSegment( + blocks: IBlockInput[], + payloadEnvelopes: Map | null, + opts?: ImportBlockOpts + ): Promise { + await this.blockProcessor.processBlocksJob(blocks, opts); + + // After blocks are imported, add downloaded envelopes to their PayloadEnvelopeInput + // and trigger processing. Each block's importBlock() already created a PayloadEnvelopeInput + // in seenPayloadEnvelopeInputCache. + if (payloadEnvelopes) { + for (const [slot, envelope] of payloadEnvelopes) { + const blockRootHex = toRootHex(envelope.message.beaconBlockRoot); + const payloadInput = this.seenPayloadEnvelopeInputCache.get(blockRootHex); + if (!payloadInput || payloadInput.hasPayloadEnvelope()) continue; + + payloadInput.addPayloadEnvelope({ + envelope, + source: PayloadEnvelopeInputSource.byRange, + seenTimestampSec: Date.now() / 1000, + }); + + if (payloadInput.isComplete()) { + this.processExecutionPayload(payloadInput, {validSignature: false}).catch((e) => { + this.logger.debug("Error processing envelope from range sync", {slot, root: blockRootHex}, e as Error); + }); + } + } + } } async processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise { diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 1bb42bfddd30..e97d436e3aa5 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -248,7 +248,11 @@ export interface IBeaconChain { /** Process a block until complete */ processBlock(block: IBlockInput, opts?: ImportBlockOpts): Promise; /** Process a chain of blocks until complete */ - processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise; + processChainSegment( + blocks: IBlockInput[], + payloadEnvelopes: Map | null, + opts?: ImportBlockOpts + ): Promise; /** Process execution payload envelope: verify, import to fork choice, and persist to DB */ processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise; diff --git a/packages/beacon-node/src/sync/range/batch.ts b/packages/beacon-node/src/sync/range/batch.ts index cf03667acedb..afdaacab1303 100644 --- a/packages/beacon-node/src/sync/range/batch.ts +++ b/packages/beacon-node/src/sync/range/batch.ts @@ -1,6 +1,6 @@ import {ChainForkConfig} from "@lodestar/config"; -import {ForkName, isForkPostDeneb, isForkPostFulu} from "@lodestar/params"; -import {Epoch, RootHex, Slot, phase0} from "@lodestar/types"; +import {ForkName, isForkPostDeneb, isForkPostFulu, isForkPostGloas} from "@lodestar/params"; +import {Epoch, RootHex, Slot, gloas, phase0} from "@lodestar/types"; import {LodestarError} from "@lodestar/utils"; import {isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js"; import {IBlockInput} from "../../chain/blocks/blockInput/types.js"; @@ -46,19 +46,21 @@ export type Attempt = { export type AwaitingDownloadState = { status: BatchStatus.AwaitingDownload; blocks: IBlockInput[]; + payloadEnvelopes: Map | null; }; export type DownloadSuccessState = { status: BatchStatus.AwaitingProcessing; blocks: IBlockInput[]; + payloadEnvelopes: Map | null; }; export type BatchState = | AwaitingDownloadState - | {status: BatchStatus.Downloading; peer: PeerIdStr; blocks: IBlockInput[]} + | {status: BatchStatus.Downloading; peer: PeerIdStr; blocks: IBlockInput[]; payloadEnvelopes: Map | null} | DownloadSuccessState - | {status: BatchStatus.Processing; blocks: IBlockInput[]; attempt: Attempt} - | {status: BatchStatus.AwaitingValidation; blocks: IBlockInput[]; attempt: Attempt}; + | {status: BatchStatus.Processing; blocks: IBlockInput[]; payloadEnvelopes: Map | null; attempt: Attempt} + | {status: BatchStatus.AwaitingValidation; blocks: IBlockInput[]; payloadEnvelopes: Map | null; attempt: Attempt}; export type BatchMetadata = { startEpoch: Epoch; @@ -85,7 +87,7 @@ export class Batch { /** Block, blob and column requests that are used to determine the best peer and are used in downloadByRange */ requests: DownloadByRangeRequests; /** State of the batch. */ - state: BatchState = {status: BatchStatus.AwaitingDownload, blocks: []}; + state: BatchState = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null}; /** Peers that provided good data */ goodPeers: PeerIdStr[] = []; /** The `Attempts` that have been made and failed to send us this batch. */ @@ -129,6 +131,10 @@ export class Batch { count: this.count, step: 1, }; + const envelopesRequest: gloas.ExecutionPayloadEnvelopesByRangeRequest | undefined = isForkPostGloas(this.forkName) + ? {startSlot: this.startSlot, count: this.count} + : undefined; + if (isForkPostFulu(this.forkName) && withinValidRequestWindow) { return { blocksRequest, @@ -137,6 +143,7 @@ export class Batch { count: this.count, columns: this.custodyConfig.sampledColumns, }, + envelopesRequest, }; } if (isForkPostDeneb(this.forkName) && withinValidRequestWindow) { @@ -146,10 +153,12 @@ export class Batch { startSlot: this.startSlot, count: this.count, }, + envelopesRequest, }; } return { blocksRequest, + envelopesRequest, }; } @@ -186,6 +195,18 @@ export class Batch { } } + // Track envelope start slot for post-Gloas forks + let envelopeStartSlot = this.startSlot; + if (isForkPostGloas(this.forkName)) { + for (const blockInput of blocks) { + const blockSlot = blockInput.slot; + // Envelopes track separately - advance start slot for contiguous downloaded envelopes + if (envelopeStartSlot === blockSlot) { + envelopeStartSlot = blockSlot + 1; + } + } + } + // if the blockStartSlot or dataStartSlot is after the desired endSlot then no request will be made for the batch // because it is complete const endSlot = this.startSlot + this.count - 1; @@ -216,6 +237,13 @@ export class Batch { // dataSlot will still have a value but do not create a request for preDeneb forks } + if (isForkPostGloas(this.forkName) && envelopeStartSlot <= endSlot) { + requests.envelopesRequest = { + startSlot: envelopeStartSlot, + count: endSlot - envelopeStartSlot + 1, + }; + } + return requests; } @@ -263,6 +291,10 @@ export class Batch { return this.state.blocks; } + getPayloadEnvelopes(): Map | null { + return this.state.payloadEnvelopes; + } + /** * AwaitingDownload -> Downloading */ @@ -271,13 +303,13 @@ export class Batch { throw new BatchError(this.wrongStatusErrorType(BatchStatus.AwaitingDownload)); } - this.state = {status: BatchStatus.Downloading, peer, blocks: this.state.blocks}; + this.state = {status: BatchStatus.Downloading, peer, blocks: this.state.blocks, payloadEnvelopes: this.state.payloadEnvelopes}; } /** * Downloading -> AwaitingProcessing */ - downloadingSuccess(peer: PeerIdStr, blocks: IBlockInput[]): DownloadSuccessState { + downloadingSuccess(peer: PeerIdStr, blocks: IBlockInput[], payloadEnvelopes: Map | null): DownloadSuccessState { if (this.state.status !== BatchStatus.Downloading) { throw new BatchError(this.wrongStatusErrorType(BatchStatus.Downloading)); } @@ -305,11 +337,13 @@ export class Batch { status: this.state.status, }); } + const newPayloadEnvelopes = payloadEnvelopes ?? this.state.payloadEnvelopes; + if (allComplete) { - this.state = {status: BatchStatus.AwaitingProcessing, blocks}; + this.state = {status: BatchStatus.AwaitingProcessing, blocks, payloadEnvelopes: newPayloadEnvelopes}; } else { this.requests = this.getRequests(blocks); - this.state = {status: BatchStatus.AwaitingDownload, blocks}; + this.state = {status: BatchStatus.AwaitingDownload, blocks, payloadEnvelopes: newPayloadEnvelopes}; } return this.state as DownloadSuccessState; @@ -328,25 +362,26 @@ export class Batch { throw new BatchError(this.errorType({code: BatchErrorCode.MAX_DOWNLOAD_ATTEMPTS})); } - this.state = {status: BatchStatus.AwaitingDownload, blocks: this.state.blocks}; + this.state = {status: BatchStatus.AwaitingDownload, blocks: this.state.blocks, payloadEnvelopes: this.state.payloadEnvelopes}; } /** * AwaitingProcessing -> Processing */ - startProcessing(): IBlockInput[] { + startProcessing(): {blocks: IBlockInput[]; payloadEnvelopes: Map | null} { if (this.state.status !== BatchStatus.AwaitingProcessing) { throw new BatchError(this.wrongStatusErrorType(BatchStatus.AwaitingProcessing)); } const blocks = this.state.blocks; + const payloadEnvelopes = this.state.payloadEnvelopes; const hash = hashBlocks(blocks, this.config); // tracks blocks to report peer on processing error // Reset goodPeers in case another download attempt needs to be made. When Attempt is successful or not the peers // that the data came from will be handled by the Attempt that goes for processing const peers = this.goodPeers; this.goodPeers = []; - this.state = {status: BatchStatus.Processing, blocks, attempt: {peers, hash}}; - return blocks; + this.state = {status: BatchStatus.Processing, blocks, payloadEnvelopes, attempt: {peers, hash}}; + return {blocks, payloadEnvelopes}; } /** @@ -357,7 +392,7 @@ export class Batch { throw new BatchError(this.wrongStatusErrorType(BatchStatus.Processing)); } - this.state = {status: BatchStatus.AwaitingValidation, blocks: this.state.blocks, attempt: this.state.attempt}; + this.state = {status: BatchStatus.AwaitingValidation, blocks: this.state.blocks, payloadEnvelopes: this.state.payloadEnvelopes, attempt: this.state.attempt}; } /** @@ -408,7 +443,7 @@ export class Batch { // remove any downloaded blocks and re-attempt // TODO(fulu): need to remove the bad blocks from the SeenBlockInputCache - this.state = {status: BatchStatus.AwaitingDownload, blocks: []}; + this.state = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null}; } private onProcessingError(attempt: Attempt): void { @@ -419,7 +454,7 @@ export class Batch { // remove any downloaded blocks and re-attempt // TODO(fulu): need to remove the bad blocks from the SeenBlockInputCache - this.state = {status: BatchStatus.AwaitingDownload, blocks: []}; + this.state = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null}; } /** Helper to construct typed BatchError. Stack traces are correct as the error is thrown above */ diff --git a/packages/beacon-node/src/sync/range/chain.ts b/packages/beacon-node/src/sync/range/chain.ts index 911ce93b5bb0..dab8e6251d91 100644 --- a/packages/beacon-node/src/sync/range/chain.ts +++ b/packages/beacon-node/src/sync/range/chain.ts @@ -1,5 +1,5 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Epoch, Root, Slot} from "@lodestar/types"; +import {Epoch, Root, Slot, gloas} from "@lodestar/types"; import {ErrorAborted, LodestarError, Logger, toRootHex} from "@lodestar/utils"; import {isBlockInputBlobs, isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js"; import {BlockInputErrorCode} from "../../chain/blocks/blockInput/errors.js"; @@ -44,13 +44,19 @@ export type SyncChainFns = { * Must return if ALL blocks are processed successfully * If SOME blocks are processed must throw BlockProcessorError() */ - processChainSegment: (blocks: IBlockInput[], syncType: RangeSyncType) => Promise; + processChainSegment: ( + blocks: IBlockInput[], + payloadEnvelopes: Map | null, + syncType: RangeSyncType + ) => Promise; /** Must download blocks, and validate their range */ downloadByRange: ( peer: PeerSyncMeta, batch: Batch, syncType: RangeSyncType - ) => Promise>; + ) => Promise< + WarnResult<{blocks: IBlockInput[]; payloadEnvelopes: Map | null}, DownloadByRangeError> + >; /** Report peer for negative actions. Decouples from the full network instance */ reportPeer: (peer: PeerIdStr, action: PeerAction, actionName: string) => void; /** Gets current peer custodyColumns and earliestAvailableSlot */ @@ -516,7 +522,8 @@ export class SyncChain { }); this.metrics?.syncRange.downloadByRange.success.inc(); const {warnings, result} = res.result; - const downloadSuccessOutput = batch.downloadingSuccess(peer.peerId, result); + const {blocks: downloadedBlocks, payloadEnvelopes} = result; + const downloadSuccessOutput = batch.downloadingSuccess(peer.peerId, downloadedBlocks, payloadEnvelopes); const logMeta: Record = { blockCount: downloadSuccessOutput.blocks.length, }; @@ -578,10 +585,10 @@ export class SyncChain { * Sends `batch` to the processor. Note: batch may be empty */ private async processBatch(batch: Batch): Promise { - const blocks = batch.startProcessing(); + const {blocks, payloadEnvelopes} = batch.startProcessing(); // wrapError ensures to never call both batch success() and batch error() - const res = await wrapError(this.processChainSegment(blocks, this.syncType)); + const res = await wrapError(this.processChainSegment(blocks, payloadEnvelopes, this.syncType)); if (!res.err) { batch.processingSuccess(); diff --git a/packages/beacon-node/src/sync/range/range.ts b/packages/beacon-node/src/sync/range/range.ts index 64c284c65936..8c1fc88f90ce 100644 --- a/packages/beacon-node/src/sync/range/range.ts +++ b/packages/beacon-node/src/sync/range/range.ts @@ -172,7 +172,11 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { } /** Convenience method for `SyncChain` */ - private processChainSegment: SyncChainFns["processChainSegment"] = async (blocks, syncType) => { + private processChainSegment: SyncChainFns["processChainSegment"] = async ( + blocks, + payloadEnvelopes, + syncType + ) => { // Not trusted, verify signatures const flags: ImportBlockOpts = { // Only skip importing attestations for finalized sync. For head sync attestation are valuable. @@ -194,7 +198,7 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { // Should only be used for debugging or testing for (const block of blocks) await this.chain.processBlock(block, flags); } else { - await this.chain.processChainSegment(blocks, flags); + await this.chain.processChainSegment(blocks, payloadEnvelopes, flags); } }; @@ -209,13 +213,14 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { peerDasMetrics: this.chain.metrics?.peerDas, ...batch.getRequestsForPeer(peer), }); - const cached = cacheByRangeResponses({ + const {responses, payloadEnvelopes} = result; + const blocks = cacheByRangeResponses({ cache: this.chain.seenBlockInputCache, peerIdStr: peer.peerId, - responses: result, + responses, batchBlocks, }); - return {result: cached, warnings}; + return {result: {blocks, payloadEnvelopes}, warnings}; }; private pruneBlockInputs: SyncChainFns["pruneBlockInputs"] = (blocks: IBlockInput[]) => { diff --git a/packages/beacon-node/src/sync/utils/downloadByRange.ts b/packages/beacon-node/src/sync/utils/downloadByRange.ts index 8d554db255bd..8907f33ec88c 100644 --- a/packages/beacon-node/src/sync/utils/downloadByRange.ts +++ b/packages/beacon-node/src/sync/utils/downloadByRange.ts @@ -1,6 +1,6 @@ import {ChainForkConfig} from "@lodestar/config"; import {ForkPostDeneb, ForkPostFulu, ForkPreFulu, isForkPostFulu} from "@lodestar/params"; -import {SignedBeaconBlock, Slot, deneb, fulu, phase0} from "@lodestar/types"; +import {SignedBeaconBlock, Slot, deneb, fulu, gloas, phase0} from "@lodestar/types"; import {LodestarError, Logger, byteArrayEquals, fromHex, prettyPrintIndices, toRootHex} from "@lodestar/utils"; import { BlockInputSource, @@ -22,12 +22,14 @@ export type DownloadByRangeRequests = { blocksRequest?: phase0.BeaconBlocksByRangeRequest; blobsRequest?: deneb.BlobSidecarsByRangeRequest; columnsRequest?: fulu.DataColumnSidecarsByRangeRequest; + envelopesRequest?: gloas.ExecutionPayloadEnvelopesByRangeRequest; }; export type DownloadByRangeResponses = { blocks?: SignedBeaconBlock[]; blobSidecars?: deneb.BlobSidecars; columnSidecars?: fulu.DataColumnSidecar[]; + payloadEnvelopes?: gloas.SignedExecutionPayloadEnvelope[]; }; export type DownloadAndCacheByRangeProps = DownloadByRangeRequests & { @@ -198,8 +200,14 @@ export async function downloadByRange({ blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, peerDasMetrics, -}: DownloadAndCacheByRangeProps): Promise> { +}: DownloadAndCacheByRangeProps): Promise< + WarnResult< + {responses: ValidatedResponses; payloadEnvelopes: Map | null}, + DownloadByRangeError + > +> { let response: DownloadByRangeResponses; try { response = await requestByRange({ @@ -208,6 +216,7 @@ export async function downloadByRange({ blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, }); } catch (err) { throw new DownloadByRangeError({ @@ -217,17 +226,16 @@ export async function downloadByRange({ }); } - const validated = await validateResponses({ + return validateResponses({ config, batchBlocks, blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, peerDasMetrics, ...response, }); - - return validated; } /** @@ -239,6 +247,7 @@ export async function requestByRange({ blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, }: DownloadByRangeRequests & { network: INetwork; peerIdStr: PeerIdStr; @@ -246,6 +255,7 @@ export async function requestByRange({ let blocks: undefined | SignedBeaconBlock[]; let blobSidecars: undefined | deneb.BlobSidecars; let columnSidecars: undefined | fulu.DataColumnSidecar[]; + let payloadEnvelopes: undefined | gloas.SignedExecutionPayloadEnvelope[]; const requests: Promise[] = []; @@ -273,12 +283,21 @@ export async function requestByRange({ ); } + if (envelopesRequest) { + requests.push( + network.sendExecutionPayloadEnvelopesByRange(peerIdStr, envelopesRequest).then((envelopeResponse) => { + payloadEnvelopes = envelopeResponse; + }) + ); + } + await Promise.all(requests); return { blocks, blobSidecars, columnSidecars, + payloadEnvelopes, }; } @@ -291,16 +310,23 @@ export async function validateResponses({ blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, blocks, blobSidecars, columnSidecars, + payloadEnvelopes, peerDasMetrics, }: DownloadByRangeRequests & DownloadByRangeResponses & { config: ChainForkConfig; batchBlocks?: IBlockInput[]; peerDasMetrics?: BeaconMetrics["peerDas"] | null; - }): Promise> { + }): Promise< + WarnResult< + {responses: ValidatedResponses; payloadEnvelopes: Map | null}, + DownloadByRangeError + > +> { // Blocks are always required for blob/column validation // If a blocksRequest is provided, blocks have just been downloaded // If no blocksRequest is provided, batchBlocks must have been provided from cache @@ -326,8 +352,21 @@ export async function validateResponses({ } const dataRequest = blobsRequest ?? columnsRequest; + if (!dataRequest && !envelopesRequest) { + return {result: {responses: validatedResponses, payloadEnvelopes: null}, warnings}; + } + if (!dataRequest) { - return {result: validatedResponses, warnings}; + // Only envelope validation needed + let validatedPayloadEnvelopes: Map | null = null; + if (envelopesRequest) { + validatedPayloadEnvelopes = validateEnvelopesByRangeResponse( + validatedResponses.validatedBlocks ?? [], + batchBlocks, + payloadEnvelopes ?? [] + ); + } + return {result: {responses: validatedResponses, payloadEnvelopes: validatedPayloadEnvelopes}, warnings}; } const blocksForDataValidation = getBlocksForDataValidation( @@ -385,7 +424,17 @@ export async function validateResponses({ warnings = validatedColumnSidecarsResult.warnings; } - return {result: validatedResponses, warnings}; + // Validate envelopes if an envelopes request was made + let validatedPayloadEnvelopes: Map | null = null; + if (envelopesRequest) { + validatedPayloadEnvelopes = validateEnvelopesByRangeResponse( + validatedResponses.validatedBlocks ?? [], + batchBlocks, + payloadEnvelopes ?? [] + ); + } + + return {result: {responses: validatedResponses, payloadEnvelopes: validatedPayloadEnvelopes}, warnings}; } /** @@ -882,6 +931,9 @@ export enum DownloadByRangeErrorCode { /** Cached block input type mismatches new data */ MISMATCH_BLOCK_FORK = "DOWNLOAD_BY_RANGE_ERROR_MISMATCH_BLOCK_FORK", MISMATCH_BLOCK_INPUT_TYPE = "DOWNLOAD_BY_RANGE_ERROR_MISMATCH_BLOCK_INPUT_TYPE", + + /** Envelope beaconBlockRoot does not match the block's root */ + INVALID_ENVELOPE_BEACON_BLOCK_ROOT = "DOWNLOAD_BY_RANGE_ERROR_INVALID_ENVELOPE_BEACON_BLOCK_ROOT", } export type DownloadByRangeErrorType = @@ -973,6 +1025,61 @@ export type DownloadByRangeErrorType = blockRoot: string; expected: DAType; actual: DAType; + } + | { + code: DownloadByRangeErrorCode.INVALID_ENVELOPE_BEACON_BLOCK_ROOT; + slot: Slot; + expected: string; + actual: string; }; export class DownloadByRangeError extends LodestarError {} + +/** + * Validates SignedExecutionPayloadEnvelopes received for a range request. + * For each envelope whose slot appears in the downloaded blocks, verifies that + * envelope.message.beaconBlockRoot matches the corresponding block's root. + * Envelopes for slots not in the batch (orphaned payloads) are silently ignored. + */ +export function validateEnvelopesByRangeResponse( + validatedBlocks: ValidatedBlock[], + batchBlocks: IBlockInput[] | undefined, + payloadEnvelopes: gloas.SignedExecutionPayloadEnvelope[] +): Map { + // Build a map of slot -> blockRoot for all blocks in the batch + const batchBlockRoots = new Map(); + if (batchBlocks) { + for (const blockInput of batchBlocks) { + batchBlockRoots.set(blockInput.slot, fromHex(blockInput.blockRootHex)); + } + } + for (const {block, blockRoot} of validatedBlocks) { + batchBlockRoots.set(block.message.slot, blockRoot); + } + + const payloadEnvelopeMap = new Map(); + + for (const payloadEnvelope of payloadEnvelopes) { + const slot = payloadEnvelope.message.slot; + const batchBlockRoot = batchBlockRoots.get(slot); + + // Envelopes for slots not in the batch are silently ignored (orphaned payloads) + if (batchBlockRoot === undefined) { + continue; + } + + // Verify beaconBlockRoot matches the block's root + if (!byteArrayEquals(payloadEnvelope.message.beaconBlockRoot, batchBlockRoot)) { + throw new DownloadByRangeError({ + code: DownloadByRangeErrorCode.INVALID_ENVELOPE_BEACON_BLOCK_ROOT, + slot, + expected: toRootHex(batchBlockRoot), + actual: toRootHex(payloadEnvelope.message.beaconBlockRoot), + }); + } + + payloadEnvelopeMap.set(slot, payloadEnvelope); + } + + return payloadEnvelopeMap; +} diff --git a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts index 0793b05f069b..fa6c9c291a94 100644 --- a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts +++ b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts @@ -126,7 +126,7 @@ describe.skip("verify+import blocks - range sync perf test", () => { }); }); - await chain.processChainSegment(blocksImport, { + await chain.processChainSegment(blocksImport, null, { // Only skip importing attestations for finalized sync. For head sync attestation are valuable. // Importing attestations also triggers a head update, see https://github.com/ChainSafe/lodestar/issues/3804 // TODO: Review if this is okay, can we prevent some attacks by importing attestations? diff --git a/packages/beacon-node/test/unit/sync/range/batch.test.ts b/packages/beacon-node/test/unit/sync/range/batch.test.ts index 089f0b68bec0..86af12348b01 100644 --- a/packages/beacon-node/test/unit/sync/range/batch.test.ts +++ b/packages/beacon-node/test/unit/sync/range/batch.test.ts @@ -285,16 +285,20 @@ describe("sync / range / batch", async () => { // retry download: AwaitingDownload -> Downloading // downloadingSuccess: Downloading -> AwaitingProcessing batch.startDownloading(peer); - batch.downloadingSuccess(peer, [ - BlockInputPreData.createFromBlock({ - block: ssz.capella.SignedBeaconBlock.defaultValue(), - blockRootHex: "0x1234", - source: BlockInputSource.byRoot, - seenTimestampSec: Date.now() / 1000, - forkName: ForkName.capella, - daOutOfRange: false, - }), - ]); + batch.downloadingSuccess( + peer, + [ + BlockInputPreData.createFromBlock({ + block: ssz.capella.SignedBeaconBlock.defaultValue(), + blockRootHex: "0x1234", + source: BlockInputSource.byRoot, + seenTimestampSec: Date.now() / 1000, + forkName: ForkName.capella, + daOutOfRange: false, + }), + ], + null + ); expect(batch.state.status).toBe(BatchStatus.AwaitingProcessing); // startProcessing: AwaitingProcessing -> Processing @@ -334,7 +338,7 @@ describe("sync / range / batch", async () => { const batch = new Batch(startEpoch, config, clock, custodyConfig); expectThrowsLodestarError( - () => batch.downloadingSuccess(peer, []), + () => batch.downloadingSuccess(peer, [], null), new BatchError({ code: BatchErrorCode.WRONG_STATUS, startEpoch, diff --git a/packages/beacon-node/test/unit/sync/range/chain.test.ts b/packages/beacon-node/test/unit/sync/range/chain.test.ts index 246db737b897..394ac13e1f88 100644 --- a/packages/beacon-node/test/unit/sync/range/chain.test.ts +++ b/packages/beacon-node/test/unit/sync/range/chain.test.ts @@ -117,7 +117,7 @@ describe("sync / range / chain", () => { }) ); } - return {result: blocks, warnings: null}; + return {result: {blocks, payloadEnvelopes: null}, warnings: null}; }; const target: ChainTarget = {slot: computeStartSlotAtEpoch(targetEpoch), root: ZERO_HASH}; @@ -172,7 +172,7 @@ describe("sync / range / chain", () => { }) ); } - return {result: blocks, warnings: null}; + return {result: {blocks, payloadEnvelopes: null}, warnings: null}; }; const target: ChainTarget = {slot: computeStartSlotAtEpoch(targetEpoch), root: ZERO_HASH}; @@ -218,9 +218,9 @@ describe("sync / range / chain", () => { function logSyncChainFns(logger: Logger, fns: SyncChainFns): SyncChainFns { return { - processChainSegment(blocks, syncType) { + processChainSegment(blocks, payloadEnvelopes, syncType) { logger.debug("mock processChainSegment", {blocks: blocks.map((b) => b.slot).join(",")}); - return fns.processChainSegment(blocks, syncType); + return fns.processChainSegment(blocks, payloadEnvelopes, syncType); }, downloadByRange(peer, request, syncType) { logger.debug("mock downloadBeaconBlocksByRange", request.state.status); diff --git a/packages/beacon-node/test/unit/sync/range/utils/batches.test.ts b/packages/beacon-node/test/unit/sync/range/utils/batches.test.ts index a33b255b1fa3..d3f5b849d8f6 100644 --- a/packages/beacon-node/test/unit/sync/range/utils/batches.test.ts +++ b/packages/beacon-node/test/unit/sync/range/utils/batches.test.ts @@ -229,7 +229,7 @@ describe("sync / range / batches", () => { batch.startDownloading(peer); if (status === BatchStatus.Downloading) return batch; - batch.downloadingSuccess(peer, []); + batch.downloadingSuccess(peer, [], null); if (status === BatchStatus.AwaitingProcessing) return batch; batch.startProcessing(); diff --git a/packages/beacon-node/test/unit/sync/range/utils/peerBalancer.test.ts b/packages/beacon-node/test/unit/sync/range/utils/peerBalancer.test.ts index bcac6004b288..74bf280aebb0 100644 --- a/packages/beacon-node/test/unit/sync/range/utils/peerBalancer.test.ts +++ b/packages/beacon-node/test/unit/sync/range/utils/peerBalancer.test.ts @@ -186,7 +186,7 @@ describe("sync / range / peerBalancer", () => { sampledColumns: [0, 1, 2, 3], }); console.log(blockInput.hasAllData()); - const x = batch0.downloadingSuccess(peer1.peerId, [blockInput]); + const x = batch0.downloadingSuccess(peer1.peerId, [blockInput], null); console.log("x", x); // peer2 and peer3 are the same but peer3 has a lower target slot than the previous download diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index b78e90d39071..ffd37ded65ac 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -8,6 +8,7 @@ import {verifySignatureSet} from "../util/signatureSets.js"; export type ProcessExecutionPayloadEnvelopeOpts = { verifySignature?: boolean; + verifyExecutionRequestsRoot?: boolean; }; /** @@ -23,19 +24,20 @@ export function processExecutionPayloadEnvelope( signedEnvelope: gloas.SignedExecutionPayloadEnvelope, opts?: ProcessExecutionPayloadEnvelopeOpts ): void { - const {verifySignature = true} = opts ?? {}; + const {verifySignature = true, verifyExecutionRequestsRoot = true} = opts ?? {}; const envelope = signedEnvelope.message; if (verifySignature && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); } - validateExecutionPayloadEnvelope(state, envelope); + validateExecutionPayloadEnvelope(state, envelope, verifyExecutionRequestsRoot); } function validateExecutionPayloadEnvelope( state: CachedBeaconStateGloas, - envelope: gloas.ExecutionPayloadEnvelope + envelope: gloas.ExecutionPayloadEnvelope, + verifyExecutionRequestsRoot: boolean ): void { const payload = envelope.payload; @@ -100,11 +102,14 @@ function validateExecutionPayloadEnvelope( } // Verify execution_requests_root matches bid commitment (consensus-specs#5094) - const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); - if (!byteArrayEquals(requestsRoot, committedBid.executionRequestsRoot)) { - throw new Error( - `Execution requests root mismatch envelope=${toRootHex(requestsRoot)} committedBid=${toRootHex(committedBid.executionRequestsRoot)}` - ); + // Can be skipped if already verified during gossip validation + if (verifyExecutionRequestsRoot) { + const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); + if (!byteArrayEquals(requestsRoot, committedBid.executionRequestsRoot)) { + throw new Error( + `Execution requests root mismatch envelope=${toRootHex(requestsRoot)} committedBid=${toRootHex(committedBid.executionRequestsRoot)}` + ); + } } // Verify consistency of the parent hash with respect to the previous execution payload From da8f5bd31493fd0e0b98b170862f8b4a40a0ede0 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:19:20 -0700 Subject: [PATCH 27/39] feat: unknown payload sync for incoming blocks (supersedes #9102) When a gossip block claims its parent was FULL but the parent's envelope hasn't been seen, the block is queued and the parent envelope is fetched via sendExecutionPayloadEnvelopesByRoot. Changes: - Add PARENT_PAYLOAD_UNKNOWN block error code and gossip validation - Add PendingPayloadEnvelope type for tracking missing envelopes - BlockInputSync: pendingPayloads map, triggerPayloadSearch, fetchPayloadEnvelope, onUnknownPayloadEnvelope handler - Subscribe to unknownEnvelopeBlockRoot and executionPayloadAvailable events - Add metrics for payload fetch tracking - Handle PARENT_PAYLOAD_UNKNOWN in processBlock error recovery Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/chain/errors/blockError.ts | 2 +- .../beacon-node/src/chain/validation/block.ts | 1 + .../src/metrics/metrics/lodestar.ts | 17 +++ .../src/network/processor/gossipHandlers.ts | 17 +++ packages/beacon-node/src/sync/types.ts | 10 ++ packages/beacon-node/src/sync/unknownBlock.ts | 135 +++++++++++++++++- 6 files changed, 180 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/chain/errors/blockError.ts b/packages/beacon-node/src/chain/errors/blockError.ts index 9d8e07c02a56..dccf9926d546 100644 --- a/packages/beacon-node/src/chain/errors/blockError.ts +++ b/packages/beacon-node/src/chain/errors/blockError.ts @@ -120,7 +120,7 @@ export type BlockErrorType = | {code: BlockErrorCode.TOO_MANY_KZG_COMMITMENTS; blobKzgCommitmentsLen: number; commitmentLimit: number} | {code: BlockErrorCode.BID_PARENT_ROOT_MISMATCH; bidParentRoot: RootHex; blockParentRoot: RootHex} | {code: BlockErrorCode.PARENT_EXECUTION_INVALID; parentRoot: RootHex} - | {code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN; parentBlockHash: RootHex}; + | {code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN; parentRoot: RootHex; parentBlockHash: RootHex}; export class BlockGossipError extends GossipActionError {} diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 33b9daef36ad..1c79e2717e21 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -103,6 +103,7 @@ export async function validateGossipBlock( if (chain.forkChoice.getBlockHexAndBlockHash(parentRoot, parentBlockHashHex) === null) { throw new BlockGossipError(GossipAction.IGNORE, { code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN, + parentRoot, parentBlockHash: parentBlockHashHex, }); } diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 358054bdd8e9..bb62ed95d535 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -680,6 +680,23 @@ export function createLodestarMetrics( labelNames: ["code", "client"], }), }, + pendingPayloads: register.gauge({ + name: "lodestar_sync_unknown_block_pending_payloads_size", + help: "Current size of BlockInputSync pending payloads cache", + }), + payloadRequests: register.gauge<{source: BlockInputSource}>({ + name: "lodestar_sync_unknown_block_payload_requests_total", + help: "Total number of payload envelope fetch requests triggered", + labelNames: ["source"], + }), + payloadFetchSuccess: register.gauge({ + name: "lodestar_sync_unknown_block_payload_fetch_success_total", + help: "Total number of successful payload envelope fetches", + }), + payloadFetchError: register.gauge({ + name: "lodestar_sync_unknown_block_payload_fetch_error_total", + help: "Total number of errored payload envelope fetches", + }), peerBalancer: { peersMetaCount: register.gauge({ name: "lodestar_sync_unknown_block_peer_balancer_peers_meta_count", diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 00103e4ed63a..ca5379a9fca6 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -198,6 +198,23 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand } catch (e) { if (e instanceof BlockGossipError) { logger.debug("Gossip block has error", {slot, root: blockShortHex, code: e.type.code}); + if (e.type.code === BlockErrorCode.PARENT_PAYLOAD_UNKNOWN && blockInput) { + logger.debug("Gossip block has parent payload unknown", {slot, root: blockShortHex, code: e.type.code}); + // Track the child block for processing after parent envelope arrives + chain.emitter.emit(ChainEvent.blockUnknownParent, { + blockInput, + peer: peerIdStr, + source: BlockInputSource.gossip, + }); + // Trigger parent envelope fetch + chain.emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { + rootHex: e.type.parentRoot, + peer: peerIdStr, + source: BlockInputSource.gossip, + }); + throw e; + } + if (e.type.code === BlockErrorCode.PARENT_UNKNOWN && blockInput) { chain.emitter.emit(ChainEvent.blockUnknownParent, { blockInput, diff --git a/packages/beacon-node/src/sync/types.ts b/packages/beacon-node/src/sync/types.ts index ca36095c5ca8..4330ec8e7c24 100644 --- a/packages/beacon-node/src/sync/types.ts +++ b/packages/beacon-node/src/sync/types.ts @@ -1,5 +1,6 @@ import {RootHex, Slot} from "@lodestar/types"; import {IBlockInput} from "../chain/blocks/blockInput/index.js"; +import {PeerIdStr} from "../util/peerId.js"; export enum PendingBlockType { /** @@ -55,3 +56,12 @@ export function getBlockInputSyncCacheItemRootHex(block: BlockInputSyncCacheItem export function getBlockInputSyncCacheItemSlot(block: BlockInputSyncCacheItem): Slot | string { return isPendingBlockInput(block) ? block.blockInput.slot : "unknown"; } + +export type PendingPayloadEnvelope = { + status: "pending" | "fetching"; + blockRootHex: RootHex; + slot: Slot; + attempts: number; + peerIdStrings: Set; + timeAddedSec: number; +}; diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index 4875911f28b6..076b1cd7c7ae 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -1,11 +1,13 @@ import {ChainForkConfig} from "@lodestar/config"; +import {PayloadStatus} from "@lodestar/fork-choice"; import {ForkSeq} from "@lodestar/params"; import {RequestError, RequestErrorCode} from "@lodestar/reqresp"; import {computeTimeAtSlot} from "@lodestar/state-transition"; import {RootHex} from "@lodestar/types"; -import {Logger, prettyPrintIndices, pruneSetToMax, sleep} from "@lodestar/utils"; +import {Logger, fromHex, prettyPrintIndices, pruneSetToMax, sleep} from "@lodestar/utils"; import {isBlockInputBlobs, isBlockInputColumns} from "../chain/blocks/blockInput/blockInput.js"; import {BlockInputSource, IBlockInput} from "../chain/blocks/blockInput/types.js"; +import {PayloadEnvelopeInputSource} from "../chain/blocks/payloadEnvelopeInput/index.js"; import {BlockError, BlockErrorCode} from "../chain/errors/index.js"; import {ChainEvent, ChainEventData, IBeaconChain} from "../chain/index.js"; import {Metrics} from "../metrics/index.js"; @@ -22,6 +24,7 @@ import { PendingBlockInput, PendingBlockInputStatus, PendingBlockType, + PendingPayloadEnvelope, getBlockInputSyncCacheItemRootHex, getBlockInputSyncCacheItemSlot, isPendingBlockInput, @@ -32,6 +35,8 @@ import {getAllDescendantBlocks, getDescendantBlocks, getUnknownAndAncestorBlocks const MAX_ATTEMPTS_PER_BLOCK = 5; const MAX_KNOWN_BAD_BLOCKS = 500; const MAX_PENDING_BLOCKS = 100; +const MAX_PENDING_PAYLOADS = 100; +const MAX_ATTEMPTS_PER_PAYLOAD = 5; enum FetchResult { SuccessResolved = "success_resolved", @@ -78,6 +83,7 @@ export class BlockInputSync { * block RootHex -> PendingBlock. To avoid finding same root at the same time */ private readonly pendingBlocks = new Map(); + private readonly pendingPayloads = new Map(); private readonly knownBadBlocks = new Set(); private readonly maxPendingBlocks; private subscribedToNetworkEvents = false; @@ -101,6 +107,9 @@ export class BlockInputSync { metrics.blockInputSync.knownBadBlocks.addCollect(() => metrics.blockInputSync.knownBadBlocks.set(this.knownBadBlocks.size) ); + metrics.blockInputSync.pendingPayloads?.addCollect(() => + metrics.blockInputSync.pendingPayloads?.set(this.pendingPayloads.size) + ); } } @@ -116,6 +125,7 @@ export class BlockInputSync { this.chain.emitter.on(ChainEvent.unknownBlockRoot, this.onUnknownBlockRoot); this.chain.emitter.on(ChainEvent.incompleteBlockInput, this.onIncompleteBlockInput); this.chain.emitter.on(ChainEvent.blockUnknownParent, this.onUnknownParent); + this.chain.emitter.on(ChainEvent.unknownEnvelopeBlockRoot, this.onUnknownPayloadEnvelope); this.network.events.on(NetworkEvent.peerConnected, this.onPeerConnected); this.network.events.on(NetworkEvent.peerDisconnected, this.onPeerDisconnected); this.subscribedToNetworkEvents = true; @@ -127,6 +137,7 @@ export class BlockInputSync { this.chain.emitter.off(ChainEvent.unknownBlockRoot, this.onUnknownBlockRoot); this.chain.emitter.off(ChainEvent.incompleteBlockInput, this.onIncompleteBlockInput); this.chain.emitter.off(ChainEvent.blockUnknownParent, this.onUnknownParent); + this.chain.emitter.off(ChainEvent.unknownEnvelopeBlockRoot, this.onUnknownPayloadEnvelope); this.network.events.off(NetworkEvent.peerConnected, this.onPeerConnected); this.network.events.off(NetworkEvent.peerDisconnected, this.onPeerDisconnected); this.subscribedToNetworkEvents = false; @@ -183,6 +194,21 @@ export class BlockInputSync { } }; + /** + * Process an unknownEnvelopeBlockRoot event - fetch missing payload envelope for a known block. + */ + private onUnknownPayloadEnvelope = (data: ChainEventData[ChainEvent.unknownEnvelopeBlockRoot]): void => { + try { + const {rootHex: blockRootHex, peer} = data; + const block = this.chain.forkChoice.getBlockHexDefaultStatus(blockRootHex); + if (!block) return; + this.addPendingPayload(blockRootHex, block.slot, peer); + this.metrics?.blockInputSync.payloadRequests?.inc({source: data.source}); + } catch (e) { + this.logger.debug("Error handling unknownPayloadEnvelope event", {}, e as Error); + } + }; + private addByRootHex = (rootHex: RootHex, peerIdStr?: PeerIdStr): void => { let pendingBlock = this.pendingBlocks.get(rootHex); if (!pendingBlock) { @@ -248,6 +274,7 @@ export class BlockInputSync { const peerSyncMeta = this.network.getConnectedPeerSyncMeta(peerId); this.peerBalancer.onPeerConnected(data.peer, peerSyncMeta); this.triggerUnknownBlockSearch(); + this.triggerPayloadSearch(); } catch (e) { this.logger.debug("Error handling peerConnected event", {}, e as Error); } @@ -258,6 +285,105 @@ export class BlockInputSync { this.peerBalancer.onPeerDisconnected(peerId); }; + addPendingPayload(rootHex: RootHex, slot: number, peer?: PeerIdStr): void { + const payloadInput = this.chain.seenPayloadEnvelopeInputCache.get(rootHex); + if (payloadInput?.hasPayloadEnvelope()) return; + if (this.chain.forkChoice.getBlockHex(rootHex, PayloadStatus.FULL)) return; + if (this.pendingPayloads.size >= MAX_PENDING_PAYLOADS) return; + + let pending = this.pendingPayloads.get(rootHex); + if (!pending) { + pending = { + status: "pending", + blockRootHex: rootHex, + slot, + attempts: 0, + peerIdStrings: new Set(), + timeAddedSec: Date.now() / 1000, + }; + this.pendingPayloads.set(rootHex, pending); + } + if (peer) pending.peerIdStrings.add(peer); + this.triggerPayloadSearch(); + } + + private triggerPayloadSearch = (): void => { + if (this.pendingPayloads.size === 0) return; + if (this.network.getConnectedPeers().length === 0) return; + + const finalizedSlot = this.chain.forkChoice.getFinalizedBlock().slot; + + for (const [rootHex, pending] of this.pendingPayloads) { + if (pending.slot <= finalizedSlot) { + this.pendingPayloads.delete(rootHex); + continue; + } + if ( + this.chain.seenPayloadEnvelopeInputCache.get(rootHex)?.hasPayloadEnvelope() || + this.chain.forkChoice.getBlockHex(rootHex, PayloadStatus.FULL) + ) { + this.pendingPayloads.delete(rootHex); + continue; + } + if (!this.chain.forkChoice.hasBlockHexUnsafe(rootHex)) continue; + if (pending.status !== "pending") continue; + if (pending.attempts >= MAX_ATTEMPTS_PER_PAYLOAD) { + this.pendingPayloads.delete(rootHex); + continue; + } + this.fetchPayloadEnvelope(pending).catch((e) => { + this.logger.debug("Unexpected error - fetchPayloadEnvelope", {root: pending.blockRootHex}, e); + }); + } + }; + + private async fetchPayloadEnvelope(pending: PendingPayloadEnvelope): Promise { + pending.status = "fetching"; + pending.attempts++; + try { + const peerMeta = this.peerBalancer.bestPeerForPendingColumns(new Set(), new Set()); + if (!peerMeta) { + pending.status = "pending"; + return; + } + const {peerId: peer} = peerMeta; + + const envelopes = await this.network.sendExecutionPayloadEnvelopesByRoot(peer, [fromHex(pending.blockRootHex)]); + if (envelopes.length === 0) { + pending.status = "pending"; + return; + } + + const envelope = envelopes[0]; + const payloadInput = this.chain.seenPayloadEnvelopeInputCache.get(pending.blockRootHex); + if (!payloadInput) { + this.logger.debug("PayloadEnvelopeInput missing for fetched envelope", {root: pending.blockRootHex}); + pending.status = "pending"; + return; + } + + payloadInput.addPayloadEnvelope({ + envelope, + source: PayloadEnvelopeInputSource.byRoot, + seenTimestampSec: Date.now() / 1000, + peerIdStr: peer, + }); + + if (payloadInput.isComplete()) { + await this.chain.processExecutionPayload(payloadInput); + } + + this.pendingPayloads.delete(pending.blockRootHex); + this.metrics?.blockInputSync.payloadFetchSuccess?.inc(); + // Re-trigger block search since pending blocks may now be processable + this.triggerUnknownBlockSearch(); + } catch (e) { + this.logger.debug("Error fetching payload envelope", {root: pending.blockRootHex}, e as Error); + pending.status = "pending"; + this.metrics?.blockInputSync.payloadFetchError?.inc(); + } + } + /** * Gather tip parent blocks with unknown parent and do a search for all of them */ @@ -456,6 +582,12 @@ export class BlockInputSync { pendingBlock.status = PendingBlockInputStatus.downloaded; break; + case BlockErrorCode.PARENT_PAYLOAD_UNKNOWN: + this.logger.debug("Block parent payload unknown", errorData, res.err); + this.addPendingPayload(res.err.type.parentRoot, pendingBlock.blockInput.slot - 1); + pendingBlock.status = PendingBlockInputStatus.downloaded; + break; + case BlockErrorCode.EXECUTION_ENGINE_ERROR: // Removing the block(s) without penalizing the peers, hoping for EL to // recover on a latter download + verify attempt @@ -671,6 +803,7 @@ export class BlockInputSync { for (const block of badPendingBlocks) { const rootHex = getBlockInputSyncCacheItemRootHex(block); this.pendingBlocks.delete(rootHex); + this.pendingPayloads.delete(rootHex); this.chain.seenBlockInputCache.prune(rootHex); this.logger.debug("Removing bad/unknown/incomplete BlockInputSyncCacheItem", { slot, From c360e70e5190d560620f73fa65bcb6fb3769fe00 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:32:54 -0700 Subject: [PATCH 28/39] feat: Gloas DataColumnSidecar validation in range sync Handle both Fulu and Gloas column shapes in validateColumnsByRangeResponse. Fulu columns use signedBlockHeader.message.slot, Gloas columns use slot directly. Dispatch to validateGloasBlockDataColumnSidecars vs validateFuluBlockDataColumnSidecars based on fork. Gloas columns in cacheByRangeResponses are skipped (routed to PayloadEnvelopeInput by the caller, not to IBlockInput). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/sync/utils/downloadByRange.ts | 69 +++++++++++-------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/packages/beacon-node/src/sync/utils/downloadByRange.ts b/packages/beacon-node/src/sync/utils/downloadByRange.ts index 8907f33ec88c..cb88a9e3fd68 100644 --- a/packages/beacon-node/src/sync/utils/downloadByRange.ts +++ b/packages/beacon-node/src/sync/utils/downloadByRange.ts @@ -1,6 +1,6 @@ import {ChainForkConfig} from "@lodestar/config"; -import {ForkPostDeneb, ForkPostFulu, ForkPreFulu, isForkPostFulu} from "@lodestar/params"; -import {SignedBeaconBlock, Slot, deneb, fulu, gloas, phase0} from "@lodestar/types"; +import {ForkPostDeneb, ForkPostFulu, ForkPreFulu, isForkPostFulu, isForkPostGloas} from "@lodestar/params"; +import {DataColumnSidecar, SignedBeaconBlock, Slot, deneb, fulu, gloas, isGloasDataColumnSidecar, phase0} from "@lodestar/types"; import {LodestarError, Logger, byteArrayEquals, fromHex, prettyPrintIndices, toRootHex} from "@lodestar/utils"; import { BlockInputSource, @@ -11,7 +11,7 @@ import { } from "../../chain/blocks/blockInput/index.js"; import {SeenBlockInput} from "../../chain/seenCache/seenGossipBlockInput.js"; import {validateBlockBlobSidecars} from "../../chain/validation/blobSidecar.js"; -import {validateFuluBlockDataColumnSidecars} from "../../chain/validation/dataColumnSidecar.js"; +import {validateFuluBlockDataColumnSidecars, validateGloasBlockDataColumnSidecars} from "../../chain/validation/dataColumnSidecar.js"; import {BeaconMetrics} from "../../metrics/metrics/beacon.js"; import {INetwork} from "../../network/index.js"; import {getBlobKzgCommitments} from "../../util/dataColumns.js"; @@ -28,7 +28,7 @@ export type DownloadByRangeRequests = { export type DownloadByRangeResponses = { blocks?: SignedBeaconBlock[]; blobSidecars?: deneb.BlobSidecars; - columnSidecars?: fulu.DataColumnSidecar[]; + columnSidecars?: DataColumnSidecar[]; payloadEnvelopes?: gloas.SignedExecutionPayloadEnvelope[]; }; @@ -60,7 +60,7 @@ export type ValidatedBlobSidecars = { export type ValidatedColumnSidecars = { blockRoot: Uint8Array; - columnSidecars: fulu.DataColumnSidecar[]; + columnSidecars: DataColumnSidecar[]; }; export type ValidatedResponses = { @@ -152,12 +152,17 @@ export function cacheByRangeResponses({ } for (const {blockRoot, columnSidecars} of responses.validatedColumnSidecars ?? []) { - const dataSlot = columnSidecars.at(0)?.signedBlockHeader.message.slot; - if (dataSlot === undefined) { + const firstColumn = columnSidecars[0]; + if (!firstColumn) { throw new Error( `Coding Error: empty columnSidecars returned for blockRoot=${toRootHex(blockRoot)} from validation functions` ); } + // Gloas columns are added to PayloadEnvelopeInput by the caller, not to IBlockInput + if (isGloasDataColumnSidecar(firstColumn)) continue; + + const fuluColumns = columnSidecars as fulu.DataColumnSidecar[]; + const dataSlot = firstColumn.signedBlockHeader.message.slot; const existing = updatedBatchBlocks.get(dataSlot); const blockRootHex = toRootHex(blockRoot); @@ -174,7 +179,7 @@ export function cacheByRangeResponses({ actual: existing.type, }); } - for (const columnSidecar of columnSidecars) { + for (const columnSidecar of fuluColumns) { // will throw if root hex does not match (meaning we are following the wrong chain) existing.addColumn( { @@ -254,7 +259,7 @@ export async function requestByRange({ }): Promise { let blocks: undefined | SignedBeaconBlock[]; let blobSidecars: undefined | deneb.BlobSidecars; - let columnSidecars: undefined | fulu.DataColumnSidecar[]; + let columnSidecars: undefined | DataColumnSidecar[]; let payloadEnvelopes: undefined | gloas.SignedExecutionPayloadEnvelope[]; const requests: Promise[] = []; @@ -278,7 +283,7 @@ export async function requestByRange({ if (columnsRequest) { requests.push( network.sendDataColumnSidecarsByRange(peerIdStr, columnsRequest).then((columnResponse) => { - columnSidecars = columnResponse as fulu.DataColumnSidecar[]; + columnSidecars = columnResponse; }) ); } @@ -664,19 +669,19 @@ export async function validateColumnsByRangeResponse( config: ChainForkConfig, request: fulu.DataColumnSidecarsByRangeRequest, blocks: ValidatedBlock[], - columnSidecars: fulu.DataColumnSidecar[], + columnSidecars: DataColumnSidecar[], peerDasMetrics?: BeaconMetrics["peerDas"] | null ): Promise> { const warnings: DownloadByRangeError[] = []; - // TODO GLOAS: Extend by range column sync to support gloas.DataColumnSidecar and - // validate against the block bid commitments instead of the fulu signed header shape - const seenColumns = new Map>(); + const seenColumns = new Map>(); let currentSlot = -1; let currentIndex = -1; // Check for duplicates and order for (const columnSidecar of columnSidecars) { - const slot = columnSidecar.signedBlockHeader.message.slot; + const slot = isGloasDataColumnSidecar(columnSidecar) + ? columnSidecar.slot + : columnSidecar.signedBlockHeader.message.slot; let seenSlotColumns = seenColumns.get(slot); if (!seenSlotColumns) { seenSlotColumns = new Map(); @@ -735,20 +740,20 @@ export async function validateColumnsByRangeResponse( const slot = block.message.slot; const rootHex = toRootHex(blockRoot); const forkName = config.getForkName(slot); - const columnSidecarsMap: Map = seenColumns.get(slot) ?? new Map(); + const columnSidecarsMap: Map = seenColumns.get(slot) ?? new Map(); const columnSidecars = Array.from(columnSidecarsMap.values()).sort((a, b) => a.index - b.index); let blobCount: number; if (!isForkPostFulu(forkName)) { - const dataSlot = columnSidecars.at(0)?.signedBlockHeader.message.slot; throw new DownloadByRangeError({ code: DownloadByRangeErrorCode.MISMATCH_BLOCK_FORK, slot, blockFork: forkName, - dataFork: dataSlot ? config.getForkName(dataSlot) : "unknown", + dataFork: "unknown", }); } - blobCount = getBlobKzgCommitments(forkName, block as SignedBeaconBlock).length; + const kzgCommitments = getBlobKzgCommitments(forkName, block as SignedBeaconBlock); + blobCount = kzgCommitments.length; if (columnSidecars.length === 0) { if (!blobCount) { @@ -817,15 +822,25 @@ export async function validateColumnsByRangeResponse( ); } + const validatePromise = isForkPostGloas(forkName) + ? validateGloasBlockDataColumnSidecars( + slot, + blockRoot, + kzgCommitments, + columnSidecars as gloas.DataColumnSidecar[], + peerDasMetrics + ) + : validateFuluBlockDataColumnSidecars( + null, // do not pass chain here so we do not validate header signature + slot, + blockRoot, + blobCount, + columnSidecars as fulu.DataColumnSidecar[], + peerDasMetrics + ); + validationPromises.push( - validateFuluBlockDataColumnSidecars( - null, // do not pass chain here so we do not validate header signature - slot, - blockRoot, - blobCount, - columnSidecars, - peerDasMetrics - ).then(() => ({ + validatePromise.then(() => ({ blockRoot, columnSidecars, })) From 4474255d276418e9e35e095d59fbbc7374eb5a94 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:32:34 -0700 Subject: [PATCH 29/39] fix: guard payload-seen check on known block and use seenPayloadEnvelope - Only check payload-seen when block is known; unknown blocks are handled later by verifyHeadBlockAndTargetRoot which enables the API retry path for unknown roots - Use chain.seenPayloadEnvelope which checks both the gossip cache (seenPayloadEnvelopeInputCache) and fork choice FULL variant Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/beacon-node/src/chain/validation/aggregateAndProof.ts | 1 + packages/beacon-node/src/chain/validation/attestation.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts index 25cb272cb8fa..91032d12255a 100644 --- a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts +++ b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts @@ -96,6 +96,7 @@ async function validateAggregateAndProof( // the corresponding execution payload for `block` has been seen (a client MAY queue // attestations for processing once the payload is retrieved and SHOULD request the // payload envelope via `ExecutionPayloadEnvelopesByRoot`). + // Only check when block is known; unknown blocks are handled by `verifyHeadBlockAndTargetRoot` if (block !== null && attData.index === 1 && !chain.seenPayloadEnvelope(toRootHex(attData.beaconBlockRoot))) { throw new AttestationError(GossipAction.IGNORE, { code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN, diff --git a/packages/beacon-node/src/chain/validation/attestation.ts b/packages/beacon-node/src/chain/validation/attestation.ts index f3b7b8863f57..1d09a76c547b 100644 --- a/packages/beacon-node/src/chain/validation/attestation.ts +++ b/packages/beacon-node/src/chain/validation/attestation.ts @@ -322,6 +322,7 @@ async function validateAttestationNoSignatureCheck( // the corresponding execution payload for `block` has been seen (a client MAY queue // attestations for processing once the payload is retrieved and SHOULD request the // payload envelope via `ExecutionPayloadEnvelopesByRoot`). + // Only check when block is known; unknown blocks are handled by `verifyHeadBlockAndTargetRoot` if (block !== null && attData.index === 1 && !chain.seenPayloadEnvelope(toRootHex(attData.beaconBlockRoot))) { throw new AttestationError(GossipAction.IGNORE, { code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN, From 4923d105f1abe26b6adfbbdb11daf779368b0172 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:40:17 -0700 Subject: [PATCH 30/39] Update comments --- packages/beacon-node/src/chain/validation/aggregateAndProof.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts index 91032d12255a..25cb272cb8fa 100644 --- a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts +++ b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts @@ -96,7 +96,6 @@ async function validateAggregateAndProof( // the corresponding execution payload for `block` has been seen (a client MAY queue // attestations for processing once the payload is retrieved and SHOULD request the // payload envelope via `ExecutionPayloadEnvelopesByRoot`). - // Only check when block is known; unknown blocks are handled by `verifyHeadBlockAndTargetRoot` if (block !== null && attData.index === 1 && !chain.seenPayloadEnvelope(toRootHex(attData.beaconBlockRoot))) { throw new AttestationError(GossipAction.IGNORE, { code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN, From 78ca807768f76d17cffc67fabcd17e27041048c5 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:41:37 -0700 Subject: [PATCH 31/39] Update comments --- packages/beacon-node/src/chain/validation/attestation.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/beacon-node/src/chain/validation/attestation.ts b/packages/beacon-node/src/chain/validation/attestation.ts index 1d09a76c547b..f3b7b8863f57 100644 --- a/packages/beacon-node/src/chain/validation/attestation.ts +++ b/packages/beacon-node/src/chain/validation/attestation.ts @@ -322,7 +322,6 @@ async function validateAttestationNoSignatureCheck( // the corresponding execution payload for `block` has been seen (a client MAY queue // attestations for processing once the payload is retrieved and SHOULD request the // payload envelope via `ExecutionPayloadEnvelopesByRoot`). - // Only check when block is known; unknown blocks are handled by `verifyHeadBlockAndTargetRoot` if (block !== null && attData.index === 1 && !chain.seenPayloadEnvelope(toRootHex(attData.beaconBlockRoot))) { throw new AttestationError(GossipAction.IGNORE, { code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN, From 190fd13a3d7e6d9604712b0d9492e58df8f50904 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:16:16 -0700 Subject: [PATCH 32/39] Fix spec test --- .../test/spec/presets/operations.test.ts | 8 ++++++++ .../src/block/processWithdrawals.ts | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index 26fd54409134..d6cb02d5b2f2 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -10,6 +10,7 @@ import { CachedBeaconStateElectra, CachedBeaconStateGloas, ExecutionPayloadStatus, + processSlots, getBlockRootAtSlot, } from "@lodestar/state-transition"; import * as blockFns from "@lodestar/state-transition/block"; @@ -117,6 +118,13 @@ const operationFns: Record> = blockFns.processExecutionPayloadBid(state as CachedBeaconStateGloas, testCase.block); }, + parent_execution_payload: (state, testCase: {block: gloas.BeaconBlock}): CachedBeaconStateAllForks => { + // Spec test calls process_slots then process_parent_execution_payload + const postState = processSlots(state, testCase.block.slot); + blockFns.processParentExecutionPayload(postState as CachedBeaconStateGloas, testCase.block); + return postState; + }, + payload_attestation: (state, testCase: {payload_attestation: gloas.PayloadAttestation}) => { blockFns.processPayloadAttestation(state as CachedBeaconStateGloas, testCase.payload_attestation); }, diff --git a/packages/state-transition/src/block/processWithdrawals.ts b/packages/state-transition/src/block/processWithdrawals.ts index ba731b51d141..ad4036e0cd13 100644 --- a/packages/state-transition/src/block/processWithdrawals.ts +++ b/packages/state-transition/src/block/processWithdrawals.ts @@ -11,7 +11,12 @@ import { import {BuilderIndex, ValidatorIndex, capella, ssz} from "@lodestar/types"; import {byteArrayEquals, toRootHex} from "@lodestar/utils"; import {CachedBeaconStateCapella, CachedBeaconStateElectra, CachedBeaconStateGloas} from "../types.js"; -import {convertBuilderIndexToValidatorIndex, convertValidatorIndexToBuilderIndex, isBuilderIndex} from "../util/gloas.js"; +import { + convertBuilderIndexToValidatorIndex, + convertValidatorIndexToBuilderIndex, + isBuilderIndex, + isParentBlockFull, +} from "../util/gloas.js"; import { decreaseBalance, getMaxEffectiveBalance, @@ -26,8 +31,12 @@ export function processWithdrawals( state: CachedBeaconStateCapella | CachedBeaconStateElectra | CachedBeaconStateGloas, payload?: capella.FullOrBlindedExecutionPayload ): void { - // After consensus-specs#5094, processParentExecutionPayload has already handled parent effects - // before processWithdrawals is called, so no early return needed for Gloas. + // Return early if the parent block is empty. + // After processParentExecutionPayload runs, latestBlockHash is updated only if parent was FULL. + // If still mismatched, the parent was EMPTY and no withdrawals should be computed. + if (fork >= ForkSeq.gloas && !isParentBlockFull(state as CachedBeaconStateGloas)) { + return; + } // processedBuilderWithdrawalsCount is withdrawals coming from builder payment since gloas (EIP-7732) // processedPartialWithdrawalsCount is withdrawals coming from EL since electra (EIP-7002) From f5821e9fe04a405c7c59248af93f829df011f707 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:26:00 -0700 Subject: [PATCH 33/39] Address comments & follow up on spec change --- .../chain/blocks/importExecutionPayload.ts | 26 ++-- .../blocks/verifyExecutionPayloadEnvelope.ts | 132 ++++++++++++++++ packages/beacon-node/src/chain/chain.ts | 18 ++- .../chain/produceBlock/produceBlockBody.ts | 53 ++++++- .../test/spec/presets/operations.test.ts | 14 +- .../fork-choice/src/forkChoice/forkChoice.ts | 4 + .../fork-choice/src/forkChoice/interface.ts | 6 + packages/state-transition/src/block/index.ts | 2 - .../block/processExecutionPayloadEnvelope.ts | 144 ------------------ .../block/processParentExecutionPayload.ts | 44 +++--- .../src/block/processWithdrawals.ts | 2 +- .../src/stateView/beaconStateView.ts | 19 ++- .../src/stateView/interface.ts | 11 +- 13 files changed, 258 insertions(+), 217 deletions(-) create mode 100644 packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts delete mode 100644 packages/state-transition/src/block/processExecutionPayloadEnvelope.ts diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 3b1c1da903f3..79ac57e6eb29 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,6 +1,7 @@ import {routes} from "@lodestar/api"; import {ExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice"; -import {getExecutionPayloadEnvelopeSignatureSet, isStatePostGloas} from "@lodestar/state-transition"; +import {isStatePostGloas} from "@lodestar/state-transition"; +import {verifyExecutionPayloadEnvelope, verifyExecutionPayloadEnvelopeSignature} from "./verifyExecutionPayloadEnvelope.js"; import {fromHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; @@ -139,24 +140,21 @@ export async function importExecutionPayload( opts.validSignature === true ? Promise.resolve(true) - : (async () => { - const signatureSet = getExecutionPayloadEnvelopeSignatureSet( - this.config, - this.pubkeyCache, - blockState, - signedEnvelope, - payloadInput.proposerIndex - ); - return this.bls.verifySignatureSets([signatureSet]); - })(), + : verifyExecutionPayloadEnvelopeSignature( + this.config, + blockState, + this.pubkeyCache, + signedEnvelope, + payloadInput.proposerIndex, + this.bls + ), ]); - // 5a. Pure envelope verification (no state mutation) + // 5a. Verify envelope fields against state (spec: verify_execution_payload_envelope) try { // When validSignature is true, the envelope came from gossip/API where both // signature and executionRequestsRoot were already verified — skip re-hashing - blockState.verifyExecutionPayloadEnvelope(signedEnvelope, { - verifySignature: false, + verifyExecutionPayloadEnvelope(this.config, blockState, envelope, { verifyExecutionRequestsRoot: !opts.validSignature, }); } catch (e) { diff --git a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts new file mode 100644 index 000000000000..428c22188408 --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts @@ -0,0 +1,132 @@ +import {BeaconConfig} from "@lodestar/config"; +import {gloas, ssz} from "@lodestar/types"; +import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; +import { + type IBeaconStateViewGloas, + type PubkeyCache, + computeTimeAtSlot, + getExecutionPayloadEnvelopeSignatureSet, +} from "@lodestar/state-transition"; +import {IBlsVerifier} from "../bls/index.js"; + +export type VerifyExecutionPayloadEnvelopeOpts = { + verifyExecutionRequestsRoot?: boolean; +}; + +/** + * Verify execution payload envelope fields against the post-block state. + * Does NOT verify signature (done separately) or call the execution engine. + * + * Spec: gloas/fork-choice.md — verify_execution_payload_envelope + */ +export function verifyExecutionPayloadEnvelope( + config: BeaconConfig, + state: IBeaconStateViewGloas, + envelope: gloas.ExecutionPayloadEnvelope, + opts?: VerifyExecutionPayloadEnvelopeOpts +): void { + const {verifyExecutionRequestsRoot = true} = opts ?? {}; + const payload = envelope.payload; + + // Compute header root without mutating state + const headerValue = {...state.latestBlockHeader}; + if (byteArrayEquals(headerValue.stateRoot, ssz.Root.defaultValue())) { + headerValue.stateRoot = state.hashTreeRoot(); + } + const headerRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(headerValue); + + // Verify consistency with the beacon block + if (!byteArrayEquals(envelope.beaconBlockRoot, headerRoot)) { + throw new Error( + `Envelope's block is not the latest block header envelope=${toRootHex(envelope.beaconBlockRoot)} latestBlockHeader=${toRootHex(headerRoot)}` + ); + } + + if (envelope.slot !== state.slot) { + throw new Error(`Slot mismatch between envelope and state envelope=${envelope.slot} state=${state.slot}`); + } + + // Verify consistency with the committed bid + const bid = state.latestExecutionPayloadBid; + if (envelope.builderIndex !== bid.builderIndex) { + throw new Error( + `Builder index mismatch between envelope and committed bid envelope=${envelope.builderIndex} bid=${bid.builderIndex}` + ); + } + + if (!byteArrayEquals(bid.prevRandao, payload.prevRandao)) { + throw new Error( + `Prev randao mismatch between bid and payload bid=${toHex(bid.prevRandao)} payload=${toHex(payload.prevRandao)}` + ); + } + + if (Number(bid.gasLimit) !== payload.gasLimit) { + throw new Error( + `Gas limit mismatch between payload and bid payload=${payload.gasLimit} bid=${Number(bid.gasLimit)}` + ); + } + + if (!byteArrayEquals(bid.blockHash, payload.blockHash)) { + throw new Error( + `Block hash mismatch between payload and bid payload=${toRootHex(payload.blockHash)} bid=${toRootHex(bid.blockHash)}` + ); + } + + // Verify execution_requests_root matches bid commitment + // Can be skipped if already verified during gossip validation + if (verifyExecutionRequestsRoot) { + const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); + if (!byteArrayEquals(requestsRoot, bid.executionRequestsRoot)) { + throw new Error( + `Execution requests root mismatch envelope=${toRootHex(requestsRoot)} bid=${toRootHex(bid.executionRequestsRoot)}` + ); + } + } + + // Verify the execution payload is valid + if (!byteArrayEquals(payload.parentHash, state.latestBlockHash)) { + throw new Error( + `Parent hash mismatch between payload and state payload=${toRootHex(payload.parentHash)} state=${toRootHex(state.latestBlockHash)}` + ); + } + + if (payload.timestamp !== computeTimeAtSlot(config, state.slot, state.genesisTime)) { + throw new Error( + `Timestamp mismatch between payload and state payload=${payload.timestamp} state=${computeTimeAtSlot(config, state.slot, state.genesisTime)}` + ); + } + + // Verify consistency with expected withdrawals + const payloadWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(payload.withdrawals); + const expectedWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(state.payloadExpectedWithdrawals); + if (!byteArrayEquals(payloadWithdrawalsRoot, expectedWithdrawalsRoot)) { + throw new Error( + `Withdrawals mismatch between payload and expected payload=${toRootHex(payloadWithdrawalsRoot)} expected=${toRootHex(expectedWithdrawalsRoot)}` + ); + } + + // Execution engine verification (verify_and_notify_new_payload) is done externally +} + +/** + * Verify the BLS signature of an execution payload envelope. + * + * Spec: gloas/fork-choice.md — verify_execution_payload_envelope_signature + */ +export async function verifyExecutionPayloadEnvelopeSignature( + config: BeaconConfig, + state: IBeaconStateViewGloas, + pubkeyCache: PubkeyCache, + signedEnvelope: gloas.SignedExecutionPayloadEnvelope, + proposerIndex: number, + bls: IBlsVerifier +): Promise { + const signatureSet = getExecutionPayloadEnvelopeSignatureSet( + config, + pubkeyCache, + state, + signedEnvelope, + proposerIndex + ); + return bls.verifySignatureSets([signatureSet]); +} diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 434e78c9dbd7..dc5a05b38c25 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -874,15 +874,21 @@ export class BeaconChain implements IBeaconChain { /** * Get execution requests from parent's payload envelope for block production. - * If parent was FULL, returns the execution requests from the cached envelope. - * If parent was EMPTY or envelope unknown, returns empty execution requests. + * Uses is_payload_verified AND should_extend_payload per spec's prepare_execution_payload. + * If parent was FULL and PTC voted timely, returns execution requests from the cached envelope. + * Otherwise returns empty execution requests (build on EMPTY variant). */ getParentExecutionRequests(parentBlockRootHex: RootHex): electra.ExecutionRequests { - const payloadInput = this.seenPayloadEnvelopeInputCache.get(parentBlockRootHex); - if (payloadInput?.hasPayloadEnvelope()) { - return payloadInput.getPayloadEnvelope().message.executionRequests; + if ( + this.forkChoice.hasPayloadHexUnsafe(parentBlockRootHex) && + this.forkChoice.shouldExtendPayload(parentBlockRootHex) + ) { + const payloadInput = this.seenPayloadEnvelopeInputCache.get(parentBlockRootHex); + if (payloadInput?.hasPayloadEnvelope()) { + return payloadInput.getPayloadEnvelope().message.executionRequests; + } } - // Parent was EMPTY or we don't have the envelope — return empty requests + // Parent was EMPTY, payload not verified, or PTC didn't vote timely — return empty requests return ssz.electra.ExecutionRequests.defaultValue(); } diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index caf0be353987..4887e503a9d3 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -18,6 +18,7 @@ import { G2_POINT_AT_INFINITY, IBeaconStateView, type IBeaconStateViewBellatrix, + type IBeaconStateViewGloas, computeTimeAtSlot, isParentBlockFull, isStatePostBellatrix, @@ -609,6 +610,8 @@ export async function prepareExecutionPayload( chain: { executionEngine: IExecutionEngine; config: ChainForkConfig; + forkChoice?: IForkChoice; + seenPayloadEnvelopeInputCache?: {get(rootHex: string): {hasPayloadEnvelope(): boolean; getPayloadEnvelope(): gloas.SignedExecutionPayloadEnvelope} | undefined}; }, logger: Logger, fork: ForkPostBellatrix, @@ -618,7 +621,40 @@ export async function prepareExecutionPayload( state: IBeaconStateViewBellatrix, suggestedFeeRecipient: string ): Promise<{prepType: PayloadPreparationType; payloadId: PayloadId}> { - const parentHash = state.latestBlockHash; + let parentHash = state.latestBlockHash; + let withdrawalsOverride: capella.Withdrawal[] | undefined; + + // For Gloas: determine FULL vs EMPTY parent per spec's prepare_execution_payload + // If extending FULL parent: apply parent payload to get correct withdrawals and use bid.blockHash + // If EMPTY parent: use state.payloadExpectedWithdrawals and bid.parentBlockHash + if (isForkPostGloas(fork) && chain.forkChoice && chain.seenPayloadEnvelopeInputCache) { + const gloasState = state as unknown as { + latestExecutionPayloadBid: { + slot: number; + blockHash: Uint8Array; + parentBlockHash: Uint8Array; + builderIndex: number; + value: number; + feeRecipient: Uint8Array; + }; + }; + const parentRootHex = toRootHex(parentBlockRoot); + + if (chain.forkChoice.shouldExtendPayload(parentRootHex)) { + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(parentRootHex); + if (payloadInput?.hasPayloadEnvelope()) { + // Build on FULL variant: apply parent payload to compute correct withdrawals, + // use bid.blockHash as EL head (per spec's prepare_execution_payload) + const gloasView = state as unknown as IBeaconStateViewGloas; + withdrawalsOverride = gloasView.getExpectedWithdrawalsForFullParent(payloadInput.getPayloadEnvelope()); + parentHash = gloasState.latestExecutionPayloadBid.blockHash; + } + } else { + // EMPTY parent: use bid.parentBlockHash as the EL head + parentHash = gloasState.latestExecutionPayloadBid.parentBlockHash; + } + } + const timestamp = computeTimeAtSlot(chain.config, state.slot, state.genesisTime); const prevRandao = state.getRandaoMix(state.epoch); @@ -653,6 +689,7 @@ export async function prepareExecutionPayload( prepareSlot: state.slot, parentBlockRoot, feeRecipient: suggestedFeeRecipient, + withdrawalsOverride, }); payloadId = await chain.executionEngine.notifyForkchoiceUpdate( @@ -752,11 +789,13 @@ function preparePayloadAttributes( prepareSlot, parentBlockRoot, feeRecipient, + withdrawalsOverride, }: { prepareState: IBeaconStateViewBellatrix; prepareSlot: Slot; parentBlockRoot: Root; feeRecipient: string; + withdrawalsOverride?: capella.Withdrawal[]; } ): SSEPayloadAttributes["payloadAttributes"] { const timestamp = computeTimeAtSlot(chain.config, prepareSlot, prepareState.genesisTime); @@ -772,15 +811,15 @@ function preparePayloadAttributes( throw new Error("Expected Capella state for withdrawals"); } - if (isStatePostGloas(prepareState) && !isParentBlockFull(prepareState)) { - // When the parent block is empty, state.payloadExpectedWithdrawals holds a batch - // already deducted from CL balances but never credited on the EL (the envelope - // was not delivered). The next payload must carry those same withdrawals to - // restore CL/EL consistency, otherwise validators permanently lose that balance. + if (withdrawalsOverride) { + // FULL parent: withdrawals computed from state with parent payload applied + (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = withdrawalsOverride; + } else if (isStatePostGloas(prepareState) && !isParentBlockFull(prepareState)) { + // EMPTY parent: use pre-computed expected withdrawals from state (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = prepareState.payloadExpectedWithdrawals; } else { - // withdrawals logic is now fork aware as it changes on electra fork post capella + // Pre-Gloas or Gloas with full parent but no override (shouldn't happen in normal flow) (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = prepareState.getExpectedWithdrawals().expectedWithdrawals; } diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index d6cb02d5b2f2..4f97fd26d79f 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import {getConfig} from "@lodestar/config/test-utils"; -import {ACTIVE_PRESET, ForkName, ForkSeq} from "@lodestar/params"; +import {ACTIVE_PRESET, ForkName} from "@lodestar/params"; import {InputType} from "@lodestar/spec-test-util"; import { BeaconStateAllForks, @@ -72,18 +72,11 @@ const operationFns: Record> = execution_payload: ( state, testCase: { - body: bellatrix.BeaconBlockBody | gloas.BeaconBlockBody; - signed_envelope: gloas.SignedExecutionPayloadEnvelope; + body: bellatrix.BeaconBlockBody; execution: {execution_valid: boolean}; } - ): CachedBeaconStateAllForks | void => { + ) => { const fork = state.config.getForkSeq(state.slot); - if (fork >= ForkSeq.gloas) { - blockFns.processExecutionPayloadEnvelope(state as CachedBeaconStateGloas, testCase.signed_envelope, { - verifySignature: true, - }); - return; - } blockFns.processExecutionPayload(fork, state as CachedBeaconStateBellatrix, testCase.body, { executionPayloadStatus: testCase.execution.execution_valid ? ExecutionPayloadStatus.valid @@ -185,7 +178,6 @@ const operations: TestRunnerFn = (fork, deposit_request: ssz.electra.DepositRequest, consolidation_request: ssz.electra.ConsolidationRequest, payload_attestation: ssz.gloas.PayloadAttestation, - signed_envelope: ssz.gloas.SignedExecutionPayloadEnvelope, }, shouldError: (testCase) => testCase.post === undefined, getExpected: (testCase) => testCase.post, diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index ab6782bc7de7..38cf8194af7f 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1074,6 +1074,10 @@ export class ForkChoice implements IForkChoice { return this.protoArray.hasPayload(blockRoot); } + shouldExtendPayload(blockRoot: RootHex): boolean { + return this.protoArray.shouldExtendPayload(blockRoot, this.proposerBoostRoot); + } + /** * Returns a MUTABLE `ProtoBlock` if the block is known **and** a descendant of the finalized root. */ diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index f2ab2e0d0bd8..065b3378f8c5 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -230,6 +230,12 @@ export interface IForkChoice { */ hasPayloadUnsafe(blockRoot: Root): boolean; hasPayloadHexUnsafe(blockRoot: RootHex): boolean; + /** + * Whether to extend the payload for a given block root. + * Checks PTC timeliness and data availability, with fallback logic. + * Spec: gloas/fork-choice.md#should_extend_payload + */ + shouldExtendPayload(blockRoot: RootHex): boolean; getSlotsPresent(windowStart: number): number; /** * Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. diff --git a/packages/state-transition/src/block/index.ts b/packages/state-transition/src/block/index.ts index 71633419e828..0822024a04ce 100644 --- a/packages/state-transition/src/block/index.ts +++ b/packages/state-transition/src/block/index.ts @@ -14,7 +14,6 @@ import {processBlockHeader} from "./processBlockHeader.js"; import {processEth1Data} from "./processEth1Data.js"; import {processExecutionPayload} from "./processExecutionPayload.js"; import {processExecutionPayloadBid} from "./processExecutionPayloadBid.js"; -import {processExecutionPayloadEnvelope} from "./processExecutionPayloadEnvelope.js"; import {processOperations} from "./processOperations.js"; import {processParentExecutionPayload} from "./processParentExecutionPayload.js"; import {processPayloadAttestation} from "./processPayloadAttestation.js"; @@ -33,7 +32,6 @@ export { processWithdrawals, processExecutionPayloadBid, processPayloadAttestation, - processExecutionPayloadEnvelope, processParentExecutionPayload, }; diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts deleted file mode 100644 index ffd37ded65ac..000000000000 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ /dev/null @@ -1,144 +0,0 @@ -import {gloas, ssz} from "@lodestar/types"; -import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; -import {getExecutionPayloadEnvelopeSignatureSet} from "../signatureSets/executionPayloadEnvelope.js"; -import {BeaconStateView} from "../stateView/beaconStateView.js"; -import {CachedBeaconStateGloas} from "../types.js"; -import {computeTimeAtSlot} from "../util/index.js"; -import {verifySignatureSet} from "../util/signatureSets.js"; - -export type ProcessExecutionPayloadEnvelopeOpts = { - verifySignature?: boolean; - verifyExecutionRequestsRoot?: boolean; -}; - -/** - * Pure verification of execution payload envelope — no state mutation. - * Payload processing is deferred to the next beacon block via processParentExecutionPayload. - * - * This function does not call the execution engine. That must be done separately. - * - * Spec: consensus-specs#5094 verify_execution_payload_envelope - */ -export function processExecutionPayloadEnvelope( - state: CachedBeaconStateGloas, - signedEnvelope: gloas.SignedExecutionPayloadEnvelope, - opts?: ProcessExecutionPayloadEnvelopeOpts -): void { - const {verifySignature = true, verifyExecutionRequestsRoot = true} = opts ?? {}; - const envelope = signedEnvelope.message; - - if (verifySignature && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { - throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); - } - - validateExecutionPayloadEnvelope(state, envelope, verifyExecutionRequestsRoot); -} - -function validateExecutionPayloadEnvelope( - state: CachedBeaconStateGloas, - envelope: gloas.ExecutionPayloadEnvelope, - verifyExecutionRequestsRoot: boolean -): void { - const payload = envelope.payload; - - // Cache latest block header state root - // Note: we read but do NOT mutate state — we compute the header root on a copy - let headerRoot: Uint8Array; - if (byteArrayEquals(state.latestBlockHeader.stateRoot, ssz.Root.defaultValue())) { - // Compute what the header root would be with the state root filled in - const header = ssz.phase0.BeaconBlockHeader.toViewDU(state.latestBlockHeader.toValue()); - header.stateRoot = state.hashTreeRoot(); - headerRoot = header.hashTreeRoot(); - } else { - headerRoot = state.latestBlockHeader.hashTreeRoot(); - } - - // Verify consistency with the beacon block - if (!byteArrayEquals(envelope.beaconBlockRoot, headerRoot)) { - throw new Error( - `Envelope's block is not the latest block header envelope=${toRootHex(envelope.beaconBlockRoot)} latestBlockHeader=${toRootHex(headerRoot)}` - ); - } - - if (envelope.slot !== state.slot) { - throw new Error(`Slot mismatch between envelope and state envelope=${envelope.slot} state=${state.slot}`); - } - - // Verify consistency with the committed bid - const committedBid = state.latestExecutionPayloadBid; - if (envelope.builderIndex !== committedBid.builderIndex) { - throw new Error( - `Builder index mismatch between envelope and committed bid envelope=${envelope.builderIndex} committedBid=${committedBid.builderIndex}` - ); - } - - if (!byteArrayEquals(committedBid.prevRandao, payload.prevRandao)) { - throw new Error( - `Prev randao mismatch between committed bid and payload committedBid=${toHex(committedBid.prevRandao)} payload=${toHex(payload.prevRandao)}` - ); - } - - // Verify consistency with expected withdrawals - const payloadWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(payload.withdrawals); - const expectedWithdrawalsRoot = state.payloadExpectedWithdrawals.hashTreeRoot(); - if (!byteArrayEquals(payloadWithdrawalsRoot, expectedWithdrawalsRoot)) { - throw new Error( - `Withdrawals mismatch between payload and expected withdrawals payload=${toRootHex(payloadWithdrawalsRoot)} expected=${toRootHex(expectedWithdrawalsRoot)}` - ); - } - - // Verify the gas_limit - if (Number(committedBid.gasLimit) !== payload.gasLimit) { - throw new Error( - `Gas limit mismatch between envelope's payload and committed bid envelope=${payload.gasLimit} committedBid=${Number(committedBid.gasLimit)}` - ); - } - - // Verify the block hash - if (!byteArrayEquals(committedBid.blockHash, payload.blockHash)) { - throw new Error( - `Block hash mismatch between envelope's payload and committed bid envelope=${toRootHex(payload.blockHash)} committedBid=${toRootHex(committedBid.blockHash)}` - ); - } - - // Verify execution_requests_root matches bid commitment (consensus-specs#5094) - // Can be skipped if already verified during gossip validation - if (verifyExecutionRequestsRoot) { - const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); - if (!byteArrayEquals(requestsRoot, committedBid.executionRequestsRoot)) { - throw new Error( - `Execution requests root mismatch envelope=${toRootHex(requestsRoot)} committedBid=${toRootHex(committedBid.executionRequestsRoot)}` - ); - } - } - - // Verify consistency of the parent hash with respect to the previous execution payload - if (!byteArrayEquals(payload.parentHash, state.latestBlockHash)) { - throw new Error( - `Parent hash mismatch between envelope's payload and state envelope=${toRootHex(payload.parentHash)} state=${toRootHex(state.latestBlockHash)}` - ); - } - - // Verify timestamp - if (payload.timestamp !== computeTimeAtSlot(state.config, state.slot, state.genesisTime)) { - throw new Error( - `Timestamp mismatch between envelope's payload and state envelope=${payload.timestamp} state=${computeTimeAtSlot(state.config, state.slot, state.genesisTime)}` - ); - } - - // Execution engine verification (verify_and_notify_new_payload) is done externally -} - -function verifyExecutionPayloadEnvelopeSignature( - state: CachedBeaconStateGloas, - signedEnvelope: gloas.SignedExecutionPayloadEnvelope -): boolean { - const signatureSet = getExecutionPayloadEnvelopeSignatureSet( - state.config, - state.epochCtx.pubkeyCache, - new BeaconStateView(state), - signedEnvelope, - state.latestBlockHeader.proposerIndex - ); - return verifySignatureSet(signatureSet); -} diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index 0560f75003fc..b639dca10b3d 100644 --- a/packages/state-transition/src/block/processParentExecutionPayload.ts +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -46,9 +46,21 @@ export function processParentExecutionPayload( * * Spec: apply_parent_execution_payload */ -function applyParentExecutionPayload( +/** + * Settle a builder payment at the given index. + * Spec: settle_builder_payment + */ +function settleBuilderPayment(state: CachedBeaconStateGloas, paymentIndex: number): void { + const payment = state.builderPendingPayments.get(paymentIndex).clone(); + if (payment.withdrawal.amount > 0) { + state.builderPendingWithdrawals.push(payment.withdrawal); + } + state.builderPendingPayments.set(paymentIndex, ssz.gloas.BuilderPendingPayment.defaultViewDU()); +} + +export function applyParentExecutionPayload( state: CachedBeaconStateGloas, - parentBid: {slot: number; blockHash: Uint8Array; builderIndex: number}, + parentBid: {slot: number; blockHash: Uint8Array; builderIndex: number; value: number; feeRecipient: Uint8Array}, requests: electra.ExecutionRequests ): void { const fork = state.config.getForkSeq(state.slot); @@ -73,23 +85,21 @@ function applyParentExecutionPayload( processConsolidationRequest(state, consolidation); } - // Queue the builder payment - let paymentIndex: number | null; + // Settle the builder payment if (parentEpoch === currentEpoch) { - paymentIndex = SLOTS_PER_EPOCH + (parentSlot % SLOTS_PER_EPOCH); + settleBuilderPayment(state, SLOTS_PER_EPOCH + (parentSlot % SLOTS_PER_EPOCH)); } else if (parentEpoch === currentEpoch - 1) { - paymentIndex = parentSlot % SLOTS_PER_EPOCH; - } else { - // Parent is older than previous epoch — payment already settled/evicted - paymentIndex = null; - } - - if (paymentIndex !== null) { - const payment = state.builderPendingPayments.get(paymentIndex).clone(); - if (payment.withdrawal.amount > 0) { - state.builderPendingWithdrawals.push(payment.withdrawal); - } - state.builderPendingPayments.set(paymentIndex, ssz.gloas.BuilderPendingPayment.defaultViewDU()); + settleBuilderPayment(state, parentSlot % SLOTS_PER_EPOCH); + } else if (parentBid.value > 0) { + // Parent is older than previous epoch — payment entry already settled/evicted. + // Directly append the withdrawal to ensure the builder gets paid. + state.builderPendingWithdrawals.push( + ssz.gloas.BuilderPendingWithdrawal.toViewDU({ + feeRecipient: parentBid.feeRecipient, + amount: parentBid.value, + builderIndex: parentBid.builderIndex, + }) + ); } // Update parent payload availability and latest block hash diff --git a/packages/state-transition/src/block/processWithdrawals.ts b/packages/state-transition/src/block/processWithdrawals.ts index ad4036e0cd13..c30faf880fe9 100644 --- a/packages/state-transition/src/block/processWithdrawals.ts +++ b/packages/state-transition/src/block/processWithdrawals.ts @@ -50,7 +50,7 @@ export function processWithdrawals( } = getExpectedWithdrawals(fork, state); const numWithdrawals = expectedWithdrawals.length; - // After gloas, withdrawals are verified later in processExecutionPayloadEnvelope + // After gloas, withdrawals are verified later in verifyExecutionPayloadEnvelope if (fork < ForkSeq.gloas) { if (payload === undefined) { throw Error("payload is required for pre-gloas processWithdrawals"); diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 02f03080a08b..1353fa3e95f1 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -28,8 +28,7 @@ import { rewards, } from "@lodestar/types"; import {Checkpoint, Fork} from "@lodestar/types/phase0"; -import {processExecutionPayloadEnvelope} from "../block/index.js"; -import {ProcessExecutionPayloadEnvelopeOpts} from "../block/processExecutionPayloadEnvelope.js"; +import {applyParentExecutionPayload} from "../block/processParentExecutionPayload.js"; import {VoluntaryExitValidity, getVoluntaryExitValidity} from "../block/processVoluntaryExit.js"; import {getExpectedWithdrawals} from "../block/processWithdrawals.js"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; @@ -794,14 +793,14 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { return new BeaconStateView(newState); } - verifyExecutionPayloadEnvelope( - signedEnvelope: gloas.SignedExecutionPayloadEnvelope, - opts?: ProcessExecutionPayloadEnvelopeOpts - ): void { - const fork = this.config.getForkName(this.cachedState.slot); - if (!isForkPostGloas(fork)) { - throw Error(`verifyExecutionPayloadEnvelope is only available for gloas+ forks, got fork=${fork}`); + getExpectedWithdrawalsForFullParent(envelope: gloas.SignedExecutionPayloadEnvelope): capella.Withdrawal[] { + const fork = this.config.getForkSeq(this.cachedState.slot); + if (!isForkPostGloas(this.config.getForkName(this.cachedState.slot))) { + throw Error("getExpectedWithdrawalsForFullParent is only available for gloas+ forks"); } - processExecutionPayloadEnvelope(this.cachedState as CachedBeaconStateGloas, signedEnvelope, opts); + const stateCopy = this.cachedState.clone(true) as CachedBeaconStateGloas; + applyParentExecutionPayload(stateCopy, stateCopy.latestExecutionPayloadBid, envelope.message.executionRequests); + const {expectedWithdrawals} = getExpectedWithdrawals(fork, stateCopy); + return expectedWithdrawals; } } diff --git a/packages/state-transition/src/stateView/interface.ts b/packages/state-transition/src/stateView/interface.ts index 388d1d0850cd..56af9b03466f 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -41,7 +41,6 @@ import { rewards, } from "@lodestar/types"; import {Checkpoint, Fork} from "@lodestar/types/phase0"; -import {ProcessExecutionPayloadEnvelopeOpts} from "../block/processExecutionPayloadEnvelope.js"; import {VoluntaryExitValidity} from "../block/processVoluntaryExit.js"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; import {EpochTransitionCacheOpts} from "../cache/epochTransitionCache.js"; @@ -250,10 +249,12 @@ export interface IBeaconStateViewGloas extends IBeaconStateViewFulu { getBuilder(index: BuilderIndex): gloas.Builder; canBuilderCoverBid(builderIndex: BuilderIndex, bidAmount: number): boolean; getIndexInPayloadTimelinessCommittee(validatorIndex: ValidatorIndex, slot: Slot): number; - verifyExecutionPayloadEnvelope( - signedEnvelope: gloas.SignedExecutionPayloadEnvelope, - opts?: ProcessExecutionPayloadEnvelopeOpts - ): void; + /** + * Compute expected withdrawals as if the parent was FULL. + * Clones the state, applies parent payload effects, then computes withdrawals. + * Used by prepare_execution_payload when building on FULL parent. + */ + getExpectedWithdrawalsForFullParent(envelope: gloas.SignedExecutionPayloadEnvelope): capella.Withdrawal[]; } /** From b5f24e4f5c82ce3cea583f907e024761a2b2f3a6 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:37:54 -0700 Subject: [PATCH 34/39] Swap latestBlockHash and latestExecutionPayloadBid --- packages/types/src/gloas/sszTypes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index fee4b3ef0347..5c1bcc73a015 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -255,7 +255,7 @@ export const BeaconState = new ContainerType( nextSyncCommittee: altairSsz.SyncCommittee, // Execution // latestExecutionPayloadHeader: ExecutionPayloadHeader, // Removed in GLOAS:EIP7732 - latestExecutionPayloadBid: ExecutionPayloadBid, // New in GLOAS:EIP7732 + latestBlockHash: Bytes32, // New in GLOAS:EIP7732 // Withdrawals nextWithdrawalIndex: capellaSsz.BeaconState.fields.nextWithdrawalIndex, nextWithdrawalValidatorIndex: capellaSsz.BeaconState.fields.nextWithdrawalValidatorIndex, @@ -276,7 +276,7 @@ export const BeaconState = new ContainerType( executionPayloadAvailability: new BitVectorType(SLOTS_PER_HISTORICAL_ROOT), // New in GLOAS:EIP7732 builderPendingPayments: new VectorCompositeType(BuilderPendingPayment, 2 * SLOTS_PER_EPOCH), // New in GLOAS:EIP7732 builderPendingWithdrawals: new ListCompositeType(BuilderPendingWithdrawal, BUILDER_PENDING_WITHDRAWALS_LIMIT), // New in GLOAS:EIP7732 - latestBlockHash: Bytes32, // New in GLOAS:EIP7732 + latestExecutionPayloadBid: ExecutionPayloadBid, payloadExpectedWithdrawals: capellaSsz.Withdrawals, // New in GLOAS:EIP7732 ptcWindow: PtcWindow, // New in GLOAS:EIP7732 }, From fecfcb34ecaa85bd13eeabf3e7166f80dd5b5bf2 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:19:22 -0700 Subject: [PATCH 35/39] fix upgrade state --- packages/state-transition/src/slot/upgradeStateToGloas.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index a269dba199c1..9c29884ff353 100644 --- a/packages/state-transition/src/slot/upgradeStateToGloas.ts +++ b/packages/state-transition/src/slot/upgradeStateToGloas.ts @@ -48,6 +48,8 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea stateGloasView.currentSyncCommittee = stateGloasCloned.currentSyncCommittee; stateGloasView.nextSyncCommittee = stateGloasCloned.nextSyncCommittee; stateGloasView.latestExecutionPayloadBid.blockHash = stateFulu.latestExecutionPayloadHeader.blockHash; + stateGloasView.latestExecutionPayloadBid.executionRequestsRoot = + ssz.electra.ExecutionRequests.hashTreeRoot(ssz.electra.ExecutionRequests.defaultValue()); stateGloasView.nextWithdrawalIndex = stateGloasCloned.nextWithdrawalIndex; stateGloasView.nextWithdrawalValidatorIndex = stateGloasCloned.nextWithdrawalValidatorIndex; stateGloasView.historicalSummaries = stateGloasCloned.historicalSummaries; From 397aaf9df68017bc1b73ed2644c4fbd5c38508bc Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:52:10 -0700 Subject: [PATCH 36/39] Bump version, skip fast_confirmation --- packages/api/test/unit/beacon/testData/events.ts | 2 -- packages/beacon-node/test/spec/utils/specTestIterator.ts | 2 +- spec-tests-version.json | 2 +- specrefs/.ethspecify.yml | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/api/test/unit/beacon/testData/events.ts b/packages/api/test/unit/beacon/testData/events.ts index 8de345a96411..a6c8c06ad055 100644 --- a/packages/api/test/unit/beacon/testData/events.ts +++ b/packages/api/test/unit/beacon/testData/events.ts @@ -279,7 +279,6 @@ export const eventTestData: EventData = { builderIndex: 42, blockHash: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", blockRoot: "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", - stateRoot: "0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9", executionOptimistic: false, }, [EventType.executionPayloadGossip]: { @@ -287,7 +286,6 @@ export const eventTestData: EventData = { builderIndex: 42, blockHash: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", blockRoot: "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", - stateRoot: "0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9", }, [EventType.executionPayloadAvailable]: { slot: 10, diff --git a/packages/beacon-node/test/spec/utils/specTestIterator.ts b/packages/beacon-node/test/spec/utils/specTestIterator.ts index 583a84006bb5..080ff3ad3514 100644 --- a/packages/beacon-node/test/spec/utils/specTestIterator.ts +++ b/packages/beacon-node/test/spec/utils/specTestIterator.ts @@ -79,7 +79,7 @@ export const defaultSkipOpts: SkipOpts = { /^gloas\/ssz_static\/PartialDataColumn(Header|PartsMetadata|Sidecar)\/.*$/, ], skippedTests: [], - skippedRunners: [], + skippedRunners: ["fast_confirmation"], }; /** diff --git a/spec-tests-version.json b/spec-tests-version.json index 8e29f2192c02..64560a60b747 100644 --- a/spec-tests-version.json +++ b/spec-tests-version.json @@ -1,6 +1,6 @@ { "ethereumConsensusSpecsTests": { - "specVersion": "v1.7.0-alpha.4", + "specVersion": "v1.7.0-alpha.5", "specTestsRepoUrl": "https://github.com/ethereum/consensus-specs", "outputDirBase": "spec-tests", "testsToDownload": [ diff --git a/specrefs/.ethspecify.yml b/specrefs/.ethspecify.yml index 41b05a145e51..9b773fc0fcf6 100644 --- a/specrefs/.ethspecify.yml +++ b/specrefs/.ethspecify.yml @@ -1,4 +1,4 @@ -version: v1.7.0-alpha.4 +version: v1.7.0-alpha.5 style: full specrefs: From bf2747de5090d821cb1a69b3cea36a448b62ccc5 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:10:15 -0700 Subject: [PATCH 37/39] feat: implement EIP-7843 slot_number in ExecutionPayload (specs#4840) Move slot from ExecutionPayloadEnvelope to ExecutionPayload as slotNumber (uint64) and add slotNumber to PayloadAttributes per consensus-specs#4840. Updates all verification, gossip validation, signing, serialization, and SSZ byte extraction accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/src/beacon/routes/beacon/block.ts | 4 +-- .../src/api/impl/beacon/blocks/index.ts | 2 +- .../src/api/impl/validator/index.ts | 1 - .../chain/blocks/importExecutionPayload.ts | 14 +++++----- .../blocks/verifyExecutionPayloadEnvelope.ts | 4 +-- .../chain/produceBlock/produceBlockBody.ts | 4 +++ .../validation/executionPayloadEnvelope.ts | 16 +++++------ .../executionPayloadEnvelopeArchive.ts | 2 +- .../src/execution/engine/interface.ts | 1 + .../beacon-node/src/execution/engine/mock.ts | 6 ++++- .../beacon-node/src/execution/engine/types.ts | 23 ++++++++++++++++ packages/beacon-node/src/network/network.ts | 2 +- .../src/network/processor/gossipHandlers.ts | 5 ++-- .../src/sync/utils/downloadByRange.ts | 2 +- packages/beacon-node/src/util/sszBytes.ts | 27 ++++++++++++++++--- .../test/unit/util/sszBytes.test.ts | 6 ++--- .../fork-choice/src/protoArray/protoArray.ts | 1 - .../signatureSets/executionPayloadEnvelope.ts | 2 +- packages/types/src/gloas/sszTypes.ts | 11 ++++++-- packages/types/src/gloas/types.ts | 1 + packages/types/src/types.ts | 2 +- .../validator/src/services/validatorStore.ts | 6 ++--- 22 files changed, 101 insertions(+), 41 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index 6ac6377fa9d1..c4199f505bb2 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -606,7 +606,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { - const fork = config.getForkName(signedExecutionPayloadEnvelope.message.slot); + const fork = config.getForkName(signedExecutionPayloadEnvelope.message.payload.slotNumber); return { body: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.toJson(signedExecutionPayloadEnvelope), headers: { @@ -621,7 +621,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { - const fork = config.getForkName(signedExecutionPayloadEnvelope.message.slot); + const fork = config.getForkName(signedExecutionPayloadEnvelope.message.payload.slotNumber); return { body: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.serialize(signedExecutionPayloadEnvelope), headers: { diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 645b3c64387c..4ecc158f1120 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -651,7 +651,7 @@ export function getBeaconBlockApi({ async publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope}) { const seenTimestampSec = Date.now() / 1000; const envelope = signedExecutionPayloadEnvelope.message; - const slot = envelope.slot; + const slot = envelope.payload.slotNumber; const fork = config.getForkName(slot); const blockRootHex = toRootHex(envelope.beaconBlockRoot); const blockHashHex = toRootHex(envelope.payload.blockHash); diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 9d4cd0d3d53d..52a124759272 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1648,7 +1648,6 @@ export function getValidatorApi( executionRequests: executionRequests, builderIndex: BUILDER_INDEX_SELF_BUILD, beaconBlockRoot, - slot, }; logger.info("Produced execution payload envelope", { diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 79ac57e6eb29..afbdbae20ea6 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -92,12 +92,12 @@ export async function importExecutionPayload( const envelope = signedEnvelope.message; const blockRootHex = payloadInput.blockRootHex; const blockHashHex = payloadInput.getBlockHashHex(); - const fork = this.config.getForkName(envelope.slot); + const fork = this.config.getForkName(envelope.payload.slotNumber); // 1. Emit `execution_payload_available` event at the start of import - if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayloadAvailable, { - slot: envelope.slot, + slot: envelope.payload.slotNumber, blockRoot: blockRootHex, }); } @@ -203,7 +203,7 @@ export async function importExecutionPayload( if (!isQueueErrorAborted(e)) { this.logger.error( "Error pushing payload envelope to unfinalized write queue", - {slot: envelope.slot, blockRoot: blockRootHex}, + {slot: envelope.payload.slotNumber, blockRoot: blockRootHex}, e as Error ); } @@ -220,9 +220,9 @@ export async function importExecutionPayload( } // 9. Emit event after payload is fully verified and imported to fork choice - if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayload, { - slot: envelope.slot, + slot: envelope.payload.slotNumber, builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, @@ -232,7 +232,7 @@ export async function importExecutionPayload( } this.logger.verbose("Execution payload imported", { - slot: envelope.slot, + slot: envelope.payload.slotNumber, builderIndex: envelope.builderIndex, blockRoot: blockRootHex, blockHash: blockHashHex, diff --git a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts index 428c22188408..cecfbc25dc4d 100644 --- a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts @@ -42,8 +42,8 @@ export function verifyExecutionPayloadEnvelope( ); } - if (envelope.slot !== state.slot) { - throw new Error(`Slot mismatch between envelope and state envelope=${envelope.slot} state=${state.slot}`); + if (payload.slotNumber !== state.slot) { + throw new Error(`Slot mismatch between payload and state payload=${payload.slotNumber} state=${state.slot}`); } // Verify consistency with the committed bid diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 4887e503a9d3..b47cc34f024f 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -829,6 +829,10 @@ function preparePayloadAttributes( (payloadAttributes as deneb.SSEPayloadAttributes["payloadAttributes"]).parentBeaconBlockRoot = parentBlockRoot; } + if (ForkSeq[fork] >= ForkSeq.gloas) { + (payloadAttributes as PayloadAttributes).slotNumber = prepareSlot; + } + return payloadAttributes; } diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index ef58992a6bbb..831db7d8f0bd 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -53,7 +53,7 @@ async function validateExecutionPayloadEnvelope( throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, blockRoot: blockRootHex, - slot: envelope.slot, + slot: payload.slotNumber, }); } @@ -65,13 +65,13 @@ async function validateExecutionPayloadEnvelope( }); } - // [IGNORE] The envelope is from a slot greater than or equal to the latest finalized slot -- i.e. validate that `envelope.slot >= compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)` + // [IGNORE] The envelope is from a slot greater than or equal to the latest finalized slot -- i.e. validate that `payload.slotNumber >= compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)` const finalizedCheckpoint = chain.forkChoice.getFinalizedCheckpoint(); const finalizedSlot = computeStartSlotAtEpoch(finalizedCheckpoint.epoch); - if (envelope.slot < finalizedSlot) { + if (payload.slotNumber < finalizedSlot) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.BELONG_TO_FINALIZED_BLOCK, - envelopeSlot: envelope.slot, + envelopeSlot: payload.slotNumber, finalizedSlot, }); } @@ -80,11 +80,11 @@ async function validateExecutionPayloadEnvelope( // TODO GLOAS: implement this. Technically if we cannot get proto block from fork choice, // it is possible that the block didn't pass the validation - // [REJECT] `block.slot` equals `envelope.slot`. - if (block.slot !== envelope.slot) { + // [REJECT] `block.slot` equals `payload.slotNumber`. + if (block.slot !== payload.slotNumber) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { code: ExecutionPayloadEnvelopeErrorCode.SLOT_MISMATCH, - envelopeSlot: envelope.slot, + envelopeSlot: payload.slotNumber, blockSlot: block.slot, }); } @@ -124,7 +124,7 @@ async function validateExecutionPayloadEnvelope( throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.UNKNOWN_BLOCK_STATE, blockRoot: blockRootHex, - slot: envelope.slot, + slot: payload.slotNumber, }); }); if (!isStatePostGloas(blockState)) { diff --git a/packages/beacon-node/src/db/repositories/executionPayloadEnvelopeArchive.ts b/packages/beacon-node/src/db/repositories/executionPayloadEnvelopeArchive.ts index 5b282b1b1bd7..439f2de1f822 100644 --- a/packages/beacon-node/src/db/repositories/executionPayloadEnvelopeArchive.ts +++ b/packages/beacon-node/src/db/repositories/executionPayloadEnvelopeArchive.ts @@ -19,7 +19,7 @@ export class ExecutionPayloadEnvelopeArchiveRepository extends Repository).withdrawals = ssz.capella.Withdrawals.defaultValue(); } + if (ForkSeq[fork] >= ForkSeq.gloas && payloadAttributes.slotNumber != null) { + (executionPayload as gloas.ExecutionPayload).slotNumber = payloadAttributes.slotNumber; + } + this.preparingPayloads.set(payloadId, { executionPayload: serializeExecutionPayload(fork, executionPayload), blobsBundle: serializeBlobsBundle({ diff --git a/packages/beacon-node/src/execution/engine/types.ts b/packages/beacon-node/src/execution/engine/types.ts index cfc910d8d206..e3fb045dce86 100644 --- a/packages/beacon-node/src/execution/engine/types.ts +++ b/packages/beacon-node/src/execution/engine/types.ts @@ -19,6 +19,7 @@ import { capella, deneb, electra, + gloas, ssz, } from "@lodestar/types"; import {BlobAndProof} from "@lodestar/types/deneb"; @@ -180,6 +181,7 @@ export type ExecutionPayloadRpc = { withdrawals?: WithdrawalRpc[]; // Capella hardfork blobGasUsed?: QUANTITY; // DENEB excessBlobGas?: QUANTITY; // DENEB + slotNumber?: QUANTITY; // Gloas EIP-7843 }; export type WithdrawalRpc = { @@ -228,6 +230,8 @@ export type PayloadAttributesRpc = { withdrawals?: WithdrawalRpc[]; /** DATA, 32 Bytes - value for the parentBeaconBlockRoot to be used for building block */ parentBeaconBlockRoot?: DATA; + /** QUANTITY, 64 Bits - slot number for SLOTNUM opcode [EIP-7843] */ + slotNumber?: QUANTITY; }; export type ClientVersionRpc = { @@ -280,6 +284,12 @@ export function serializeExecutionPayload(fork: ForkName, data: ExecutionPayload // No changes in Electra + // Gloas adds slotNumber to the ExecutionPayload + if (ForkSeq[fork] >= ForkSeq.gloas) { + const {slotNumber} = data as gloas.ExecutionPayload; + payload.slotNumber = numToQuantity(slotNumber); + } + return payload; } @@ -375,6 +385,17 @@ export function parseExecutionPayload( // No changes in Electra + // Gloas adds slotNumber to the ExecutionPayload + if (ForkSeq[fork] >= ForkSeq.gloas) { + const {slotNumber} = data; + if (slotNumber == null) { + throw Error( + `slotNumber missing for ${fork} >= gloas executionPayload number=${executionPayload.blockNumber} hash=${data.blockHash}` + ); + } + (executionPayload as gloas.ExecutionPayload).slotNumber = quantityToNum(slotNumber); + } + return {executionPayload, executionPayloadValue, blobsBundle, executionRequests, shouldOverrideBuilder}; } @@ -385,6 +406,7 @@ export function serializePayloadAttributes(data: PayloadAttributes): PayloadAttr suggestedFeeRecipient: data.suggestedFeeRecipient, withdrawals: data.withdrawals?.map(serializeWithdrawal), parentBeaconBlockRoot: data.parentBeaconBlockRoot ? bytesToData(data.parentBeaconBlockRoot) : undefined, + slotNumber: data.slotNumber != null ? numToQuantity(data.slotNumber) : undefined, }; } @@ -401,6 +423,7 @@ export function deserializePayloadAttributes(data: PayloadAttributesRpc): Payloa suggestedFeeRecipient: data.suggestedFeeRecipient, withdrawals: data.withdrawals?.map((withdrawal) => deserializeWithdrawal(withdrawal)), parentBeaconBlockRoot: data.parentBeaconBlockRoot ? dataToBytes(data.parentBeaconBlockRoot, 32) : undefined, + slotNumber: data.slotNumber != null ? quantityToNum(data.slotNumber) : undefined, }; } diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index 623fca31039c..4cd24034df98 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -505,7 +505,7 @@ export class Network implements INetwork { } async publishSignedExecutionPayloadEnvelope(signedEnvelope: gloas.SignedExecutionPayloadEnvelope): Promise { - const epoch = computeEpochAtSlot(signedEnvelope.message.slot); + const epoch = computeEpochAtSlot(signedEnvelope.message.payload.slotNumber); const boundary = this.config.getForkBoundaryAtEpoch(epoch); return this.publishGossip( diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index ca5379a9fca6..03818f9ea7c3 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -1066,7 +1066,8 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand await validateGossipExecutionPayloadEnvelope(chain, signedEnvelope); } catch (e) { if (e instanceof ExecutionPayloadEnvelopeError) { - const {slot, beaconBlockRoot} = signedEnvelope.message; + const {beaconBlockRoot} = signedEnvelope.message; + const slot = signedEnvelope.message.payload.slotNumber; logger.debug("Gossip envelope has error", {slot, root: toRootHex(beaconBlockRoot), code: e.type.code}); if (e.type.code === ExecutionPayloadEnvelopeErrorCode.BLOCK_ROOT_UNKNOWN) { // TODO GLOAS: UnknownBlockSync to handle this @@ -1089,7 +1090,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand throw e; } - const slot = envelope.slot; + const slot = envelope.payload.slotNumber; const delaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime); metrics?.gossipExecutionPayloadEnvelope.elapsedTimeTillReceived.observe({source: OpSource.gossip}, delaySec); chain.validatorMonitor?.registerExecutionPayloadEnvelope(OpSource.gossip, delaySec, signedEnvelope); diff --git a/packages/beacon-node/src/sync/utils/downloadByRange.ts b/packages/beacon-node/src/sync/utils/downloadByRange.ts index cb88a9e3fd68..73047ea56b64 100644 --- a/packages/beacon-node/src/sync/utils/downloadByRange.ts +++ b/packages/beacon-node/src/sync/utils/downloadByRange.ts @@ -1075,7 +1075,7 @@ export function validateEnvelopesByRangeResponse( const payloadEnvelopeMap = new Map(); for (const payloadEnvelope of payloadEnvelopes) { - const slot = payloadEnvelope.message.slot; + const slot = payloadEnvelope.message.payload.slotNumber; const batchBlockRoot = batchBlockRoots.get(slot); // Envelopes for slots not in the batch are silently ignored (orphaned payloads) diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index 725d2fa6954c..23f20ee80d55 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -560,8 +560,8 @@ export function getBeaconBlockRootFromDataColumnSidecarSerialized(data: Uint8Arr * ├─ 4 bytes: executionRequests offset * ├─ 8 bytes: builderIndex (offset 108-115) * ├─ 32 bytes: beaconBlockRoot (offset 116-147) - * ├─ 8 bytes: slot (offset 148-155) - * └─ 32 bytes: stateRoot (offset 156-187) + * └─ variable: payload data (starts at envelope + 48) + * └─ ExecutionPayload fixed portion includes slotNumber at offset 528 */ const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET = 4; const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE = 96; @@ -576,8 +576,29 @@ const BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE = EXECUTION_PAYLOAD_ENVELOPE_REQUESTS_OFFSET + EXECUTION_PAYLOAD_ENVELOPE_BUILDER_INDEX_SIZE; // 116 +// Envelope fixed portion (without slot): payload_offset(4) + requests_offset(4) + builderIndex(8) + beaconBlockRoot(32) = 48 +const EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE = + EXECUTION_PAYLOAD_ENVELOPE_PAYLOAD_OFFSET + + EXECUTION_PAYLOAD_ENVELOPE_REQUESTS_OFFSET + + EXECUTION_PAYLOAD_ENVELOPE_BUILDER_INDEX_SIZE + + ROOT_SIZE; // 48 + +// slotNumber offset within ExecutionPayload fixed portion: +// parentHash(32) + feeRecipient(20) + stateRoot(32) + receiptsRoot(32) + logsBloom(256) + +// prevRandao(32) + blockNumber(8) + gasLimit(8) + gasUsed(8) + timestamp(8) + +// extraData_offset(4) + baseFeePerGas(32) + blockHash(32) + transactions_offset(4) + +// withdrawals_offset(4) + blobGasUsed(8) + excessBlobGas(8) = 528 +const SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD = 528; + +// Payload data starts right after the envelope's fixed portion +const ENVELOPE_START_IN_SIGNED = + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET + + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE; // 100 + const SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE = - BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + ROOT_SIZE; // 148 + ENVELOPE_START_IN_SIGNED + + EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE + + SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD; // 100 + 48 + 528 = 676 export function getSlotFromExecutionPayloadEnvelopeSerialized(data: Uint8Array): Slot | null { if (data.length < SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + SLOT_SIZE) { diff --git a/packages/beacon-node/test/unit/util/sszBytes.test.ts b/packages/beacon-node/test/unit/util/sszBytes.test.ts index b58562abd8cd..2fa572d9eca3 100644 --- a/packages/beacon-node/test/unit/util/sszBytes.test.ts +++ b/packages/beacon-node/test/unit/util/sszBytes.test.ts @@ -571,7 +571,7 @@ describe("SignedExecutionPayloadEnvelope SSZ serialized picking", () => { for (const {slot, blockRoot} of testCases) { it(`slot=${slot}`, () => { const envelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); - envelope.message.slot = slot; + envelope.message.payload.slotNumber = slot; envelope.message.beaconBlockRoot = fromHex(blockRoot); const bytes = ssz.gloas.SignedExecutionPayloadEnvelope.serialize(envelope); @@ -581,8 +581,8 @@ describe("SignedExecutionPayloadEnvelope SSZ serialized picking", () => { } it("getSlotFromExecutionPayloadEnvelopeSerialized - invalid data", () => { - // Slot is at offset 148, need at least 156 bytes - const invalidSizes = [0, 50, 100, 155]; + // slotNumber is at offset 676 within the serialized payload, need at least 684 bytes + const invalidSizes = [0, 50, 100, 683]; for (const size of invalidSizes) { expect(getSlotFromExecutionPayloadEnvelopeSerialized(Buffer.alloc(size))).toBeNull(); } diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 1225ebd47f31..8fd5806dd1ba 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -127,7 +127,6 @@ export class ProtoArray { block.executionPayloadBlockHash, (block as {executionPayloadNumber?: number}).executionPayloadNumber ?? 0, block.stateRoot, - null, ExecutionStatus.Valid ); } diff --git a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts index 806a18ff69f6..42855cf01c87 100644 --- a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts +++ b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts @@ -11,7 +11,7 @@ export function getExecutionPayloadEnvelopeSigningRoot( config: BeaconConfig, envelope: gloas.ExecutionPayloadEnvelope ): Uint8Array { - const domain = config.getDomain(envelope.slot, DOMAIN_BEACON_BUILDER); + const domain = config.getDomain(envelope.payload.slotNumber, DOMAIN_BEACON_BUILDER); return computeSigningRoot(ssz.gloas.ExecutionPayloadEnvelope, envelope, domain); } diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index 5c1bcc73a015..6ebf29d8087b 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -161,13 +161,20 @@ export const SignedExecutionPayloadBid = new ContainerType( {typeName: "SignedExecutionPayloadBid", jsonCase: "eth2"} ); +export const ExecutionPayload = new ContainerType( + { + ...denebSsz.ExecutionPayload.fields, + slotNumber: UintNum64, // [New in Gloas:EIP7843] + }, + {typeName: "ExecutionPayload", jsonCase: "eth2"} +); + export const ExecutionPayloadEnvelope = new ContainerType( { - payload: electraSsz.ExecutionPayload, + payload: ExecutionPayload, executionRequests: electraSsz.ExecutionRequests, builderIndex: BuilderIndex, beaconBlockRoot: Root, - slot: Slot, }, {typeName: "ExecutionPayloadEnvelope", jsonCase: "eth2"} ); diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index ecd506932544..fb282f498526 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -12,6 +12,7 @@ export type PayloadAttestationMessage = ValueOf; export type ProposerPreferences = ValueOf; export type SignedProposerPreferences = ValueOf; +export type ExecutionPayload = ValueOf; export type ExecutionPayloadBid = ValueOf; export type SignedExecutionPayloadBid = ValueOf; export type ExecutionPayloadEnvelope = ValueOf; diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 8d5562290a0c..f79dc6043cff 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -303,7 +303,7 @@ type TypesByFork = { BlindedBeaconBlock: electra.BlindedBeaconBlock; BlindedBeaconBlockBody: electra.BlindedBeaconBlockBody; SignedBlindedBeaconBlock: electra.SignedBlindedBeaconBlock; - ExecutionPayload: deneb.ExecutionPayload; + ExecutionPayload: gloas.ExecutionPayload; ExecutionPayloadHeader: deneb.ExecutionPayloadHeader; BuilderBid: electra.BuilderBid; SignedBuilderBid: electra.SignedBuilderBid; diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 4716d3498756..ea84e9f15833 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -496,11 +496,11 @@ export class ValidatorStore { logger?: LoggerVc ): Promise { // Make sure the envelope slot is not higher than the current slot to avoid potential attacks. - if (envelope.slot > currentSlot) { - throw Error(`Not signing envelope with slot ${envelope.slot} greater than current slot ${currentSlot}`); + if (envelope.payload.slotNumber > currentSlot) { + throw Error(`Not signing envelope with slot ${envelope.payload.slotNumber} greater than current slot ${currentSlot}`); } - const signingSlot = envelope.slot; + const signingSlot = envelope.payload.slotNumber; const domain = this.config.getDomain(signingSlot, DOMAIN_BEACON_BUILDER); const signingRoot = computeSigningRoot(ssz.gloas.ExecutionPayloadEnvelope, envelope, domain); From 984d50d268f91c755548f99fd0f42b78d20111b7 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:42:49 -0700 Subject: [PATCH 38/39] fix spec test --- .../beacon-node/src/chain/forkChoice/index.ts | 4 +- .../test/spec/presets/fork_choice.test.ts | 46 ++++++++++++++----- .../fork-choice/src/protoArray/protoArray.ts | 18 +------- .../src/block/processWithdrawals.ts | 14 ++++-- .../src/util/computeAnchorCheckpoint.ts | 21 +++------ 5 files changed, 53 insertions(+), 50 deletions(-) diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index dd952f218fc0..1c45f5496787 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -161,7 +161,7 @@ export function initializeForkChoiceFromFinalizedState( : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), dataAvailabilityStatus: DataAvailabilityStatus.PreData, - payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? + payloadStatus: isForkPostGloas ? PayloadStatus.EMPTY : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? parentBlockHash: isStatePostGloas(state) ? toRootHex(state.latestBlockHash) : null, }, currentSlot @@ -260,7 +260,7 @@ export function initializeForkChoiceFromUnfinalizedState( : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), dataAvailabilityStatus: DataAvailabilityStatus.PreData, - payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? + payloadStatus: isForkPostGloas ? PayloadStatus.EMPTY : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? parentBlockHash: isStatePostGloas(unfinalizedState) ? toRootHex(unfinalizedState.latestBlockHash) : null, }; diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index 0b09b62d8e14..a03dab18d814 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -20,6 +20,7 @@ import {InputType} from "@lodestar/spec-test-util"; import { BeaconStateAllForks, BeaconStateView, + IBeaconStateViewGloas, createCachedBeaconState, createPubkeyCache, isExecutionStateType, @@ -47,8 +48,13 @@ import { BlockInputPreData, BlockInputSource, } from "../../../src/chain/blocks/blockInput/index.js"; +import { + verifyExecutionPayloadEnvelope, + verifyExecutionPayloadEnvelopeSignature, +} from "../../../src/chain/blocks/verifyExecutionPayloadEnvelope.js"; import {AttestationImportOpt, BlobSidecarValidation} from "../../../src/chain/blocks/types.js"; import {BeaconChain, ChainEvent} from "../../../src/chain/index.js"; +import {RegenCaller} from "../../../src/chain/regen/index.js"; import {defaultChainOptions} from "../../../src/chain/options.js"; import {validateFuluBlockDataColumnSidecars} from "../../../src/chain/validation/dataColumnSidecar.js"; import {ZERO_HASH_HEX} from "../../../src/constants/constants.js"; @@ -382,6 +388,32 @@ const forkChoiceTest = const blockHash = toHex(envelope.message.payload.blockHash); const blockNumber = envelope.message.payload.blockNumber; + // Verify envelope against the post-block state (spec: verify_execution_payload_envelope) + const protoBlock = (chain.forkChoice as ForkChoice).getBlockHexDefaultStatus(beaconBlockRoot); + if (!protoBlock) throw Error(`Block not found for root ${beaconBlockRoot}`); + const envelopeState = await chain.regen.getBlockSlotState( + protoBlock, + protoBlock.slot, + {dontTransferCache: true}, + RegenCaller.restApi + ); + verifyExecutionPayloadEnvelope( + beaconConfig, + envelopeState as IBeaconStateViewGloas, + envelope.message + ); + + // Verify signature + const sigValid = await verifyExecutionPayloadEnvelopeSignature( + beaconConfig, + envelopeState as IBeaconStateViewGloas, + pubkeyCache, + envelope, + envelopeState.latestBlockHeader.proposerIndex, + chain.bls + ); + if (!sigValid) throw Error("Invalid execution payload envelope signature"); + // Add predefined VALID status for the payload's block hash so the EL mock accepts it executionEngineBackend.addPredefinedPayloadStatus(blockHash, { status: ExecutionPayloadStatus.VALID, @@ -586,17 +618,9 @@ const forkChoiceTest = name.includes("voting_source_beyond_two_epoch") || name.includes("justified_update_always_if_better") || name.includes("justified_update_not_realized_finality") || - // TODO GLOAS: These tests will be unskipped by https://github.com/ChainSafe/lodestar/pull/9233 - (name.includes("gloas") && - (name.includes("simple_attempted_reorg_without_enough_ffg_votes") || - name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_current_epoch") || - name.includes("include_votes_another_empty_chain_without_enough_ffg_votes_current_epoch"))) || - // TODO GLOAS: These two tests are affected by the wrong proposer boost cutoff time from the - // consensus-specs and thus have wrong expectation of proposer boost. Our implementation - // should pass these two tests after https://github.com/ethereum/consensus-specs/pull/5095 - // is included the spec release. - (name.includes("gloas/fork_choice/on_block") && - (name.endsWith("proposer_boost") || name.endsWith("proposer_boost_is_first_block"))), + // Spec test fixture bug in v1.7.0-alpha.5: wrong_withdrawals envelope SSZ data is + // byte-for-byte identical to the valid envelope, making it impossible to reject + name.endsWith("on_execution_payload_envelope__wrong_withdrawals"), }, }; }; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 5f6cbf9a4eb8..1f6ac8b55bf4 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -111,25 +111,9 @@ export class ProtoArray { // Anchor block PTC votes must be all-true per spec get_forkchoice_store: // payload_timeliness_vote={anchor_root: Vector[boolean, PTC_SIZE](True for _ in range(PTC_SIZE))} - // Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.4/specs/gloas/fork-choice.md#modified-get_forkchoice_store + // Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/fork-choice.md#modified-get_forkchoice_store if (protoArray.ptcVotes.has(block.blockRoot)) { protoArray.ptcVotes.set(block.blockRoot, BitArray.fromBoolArray(Array.from({length: PTC_SIZE}, () => true))); - - // In the spec, we have payload_states = {anchor_root: anchor_state.copy()} - // which means the anchor's "payload" is considered received - // Without FULL, blocks extending FULL from the anchor would be orphaned. - // TODO GLOAS: This is a bug in the spec. Keep this to pass the current spec test - // for now. Need to remove this when we work on v1.7.0-alpha.5 - if (block.executionPayloadBlockHash !== null) { - protoArray.onExecutionPayload( - block.blockRoot, - currentSlot, - block.executionPayloadBlockHash, - (block as {executionPayloadNumber?: number}).executionPayloadNumber ?? 0, - block.stateRoot, - ExecutionStatus.Valid - ); - } } return protoArray; diff --git a/packages/state-transition/src/block/processWithdrawals.ts b/packages/state-transition/src/block/processWithdrawals.ts index c30faf880fe9..e57041d1a1c4 100644 --- a/packages/state-transition/src/block/processWithdrawals.ts +++ b/packages/state-transition/src/block/processWithdrawals.ts @@ -10,6 +10,7 @@ import { } from "@lodestar/params"; import {BuilderIndex, ValidatorIndex, capella, ssz} from "@lodestar/types"; import {byteArrayEquals, toRootHex} from "@lodestar/utils"; +import {ZERO_HASH} from "../constants/index.js"; import {CachedBeaconStateCapella, CachedBeaconStateElectra, CachedBeaconStateGloas} from "../types.js"; import { convertBuilderIndexToValidatorIndex, @@ -31,11 +32,14 @@ export function processWithdrawals( state: CachedBeaconStateCapella | CachedBeaconStateElectra | CachedBeaconStateGloas, payload?: capella.FullOrBlindedExecutionPayload ): void { - // Return early if the parent block is empty. - // After processParentExecutionPayload runs, latestBlockHash is updated only if parent was FULL. - // If still mismatched, the parent was EMPTY and no withdrawals should be computed. - if (fork >= ForkSeq.gloas && !isParentBlockFull(state as CachedBeaconStateGloas)) { - return; + // Return early if this is genesis block or the parent block is empty. + if (fork >= ForkSeq.gloas) { + const stateGloas = state as CachedBeaconStateGloas; + const isGenesisBlock = byteArrayEquals(stateGloas.latestBlockHash, ZERO_HASH); + const isParentBlockEmpty = !isParentBlockFull(stateGloas); + if (isGenesisBlock || isParentBlockEmpty) { + return; + } } // processedBuilderWithdrawalsCount is withdrawals coming from builder payment since gloas (EIP-7732) diff --git a/packages/state-transition/src/util/computeAnchorCheckpoint.ts b/packages/state-transition/src/util/computeAnchorCheckpoint.ts index 1edb2ac57ca2..8bfe9a88efea 100644 --- a/packages/state-transition/src/util/computeAnchorCheckpoint.ts +++ b/packages/state-transition/src/util/computeAnchorCheckpoint.ts @@ -1,30 +1,21 @@ import {ChainForkConfig} from "@lodestar/config"; -import {GENESIS_SLOT, ZERO_HASH} from "@lodestar/params"; +import {ZERO_HASH} from "@lodestar/params"; import {phase0, ssz} from "@lodestar/types"; import {BeaconStateAllForks} from "../types.js"; -import {blockToHeader} from "./blockRoot.js"; import {computeCheckpointEpochAtStateSlot} from "./epoch.js"; export function computeAnchorCheckpoint( - config: ChainForkConfig, + _config: ChainForkConfig, anchorState: BeaconStateAllForks ): {checkpoint: phase0.Checkpoint; blockHeader: phase0.BeaconBlockHeader} { let blockHeader: phase0.BeaconBlockHeader; let root: Uint8Array; - const blockTypes = config.getForkTypes(anchorState.latestBlockHeader.slot); - if (anchorState.latestBlockHeader.slot === GENESIS_SLOT) { - const block = blockTypes.BeaconBlock.defaultValue(); - block.stateRoot = anchorState.hashTreeRoot(); - blockHeader = blockToHeader(config, block); - root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); - } else { - blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); - if (ssz.Root.equals(blockHeader.stateRoot, ZERO_HASH)) { - blockHeader.stateRoot = anchorState.hashTreeRoot(); - } - root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); + blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); + if (ssz.Root.equals(blockHeader.stateRoot, ZERO_HASH)) { + blockHeader.stateRoot = anchorState.hashTreeRoot(); } + root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); return { checkpoint: { From 2a3586e6b2d14fda9933e5356fb38239203c6b17 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 21 Apr 2026 18:06:36 +0200 Subject: [PATCH 39/39] feat: cache the last 2 PayloadEnvelopeInputs --- .../blocks/writePayloadEnvelopeInputToDb.ts | 7 --- packages/beacon-node/src/chain/chain.ts | 51 ++++++++++++++----- .../beacon-node/src/chain/prepareNextSlot.ts | 11 ++++ .../chain/produceBlock/produceBlockBody.ts | 21 ++++---- .../seenCache/seenPayloadEnvelopeInput.ts | 47 +++++++++-------- .../validation/executionPayloadEnvelope.ts | 6 ++- .../src/stateView/beaconStateView.ts | 4 +- .../src/stateView/interface.ts | 2 +- 8 files changed, 94 insertions(+), 55 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index d5c07a563590..f5deccde72ee 100644 --- a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -44,12 +44,5 @@ export async function persistPayloadEnvelopeInput( }, e ); - }) - .finally(() => { - this.seenPayloadEnvelopeInputCache.prune(payloadInput.blockRootHex); - this.logger.debug("Pruned payload envelope input", { - slot: payloadInput.slot, - root: payloadInput.blockRootHex, - }); }); } diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index dc5a05b38c25..c2309c42ee50 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -2,7 +2,13 @@ import path from "node:path"; import {PrivateKey} from "@libp2p/interface"; import {Type} from "@chainsafe/ssz"; import {BeaconConfig} from "@lodestar/config"; -import {CheckpointWithPayloadStatus, IForkChoice, ProtoBlock, UpdateHeadOpt} from "@lodestar/fork-choice"; +import { + CheckpointWithPayloadStatus, + IForkChoice, + PayloadStatus, + ProtoBlock, + UpdateHeadOpt, +} from "@lodestar/fork-choice"; import {LoggerNode} from "@lodestar/logger/node"; import { EFFECTIVE_BALANCE_INCREMENT, @@ -875,21 +881,42 @@ export class BeaconChain implements IBeaconChain { /** * Get execution requests from parent's payload envelope for block production. * Uses is_payload_verified AND should_extend_payload per spec's prepare_execution_payload. - * If parent was FULL and PTC voted timely, returns execution requests from the cached envelope. - * Otherwise returns empty execution requests (build on EMPTY variant). + * If parent was FULL and PTC voted timely, returns execution requests from the cached envelope + * (with DB fallback if the entry was pruned). Otherwise returns empty execution requests + * (build on EMPTY variant). */ - getParentExecutionRequests(parentBlockRootHex: RootHex): electra.ExecutionRequests { + async getParentExecutionRequests(parentBlockRootHex: RootHex): Promise { if ( - this.forkChoice.hasPayloadHexUnsafe(parentBlockRootHex) && - this.forkChoice.shouldExtendPayload(parentBlockRootHex) + !this.forkChoice.hasPayloadHexUnsafe(parentBlockRootHex) || + !this.forkChoice.shouldExtendPayload(parentBlockRootHex) ) { - const payloadInput = this.seenPayloadEnvelopeInputCache.get(parentBlockRootHex); - if (payloadInput?.hasPayloadEnvelope()) { - return payloadInput.getPayloadEnvelope().message.executionRequests; - } + // Parent was EMPTY, payload not verified, or PTC didn't vote timely — return empty requests + return ssz.electra.ExecutionRequests.defaultValue(); + } + + // Pre-gloas parents are stored as `PayloadStatus.FULL` by default in fork choice but have no + // execution payload envelope (their payload is inlined in the block). For the fork-boundary + // case (first gloas block built on the last pre-gloas block), fall back to empty requests. + const parentBlock = this.forkChoice.getBlockHex(parentBlockRootHex, PayloadStatus.FULL); + if (parentBlock && !isForkPostGloas(this.config.getForkName(parentBlock.slot))) { + return ssz.electra.ExecutionRequests.defaultValue(); } - // Parent was EMPTY, payload not verified, or PTC didn't vote timely — return empty requests - return ssz.electra.ExecutionRequests.defaultValue(); + + const payloadInput = this.seenPayloadEnvelopeInputCache.get(parentBlockRootHex); + if (payloadInput?.hasPayloadEnvelope()) { + return payloadInput.getPayloadEnvelope().message.executionRequests; + } + + // Cache miss (e.g., entry pruned in prepareNextSlot). Fall back to DB. + const envelopeFromDb = await this.db.executionPayloadEnvelope.get(fromHex(parentBlockRootHex)); + if (envelopeFromDb) { + return envelopeFromDb.message.executionRequests; + } + + // Invariant: fork choice says parent is FULL gloas, so envelope must exist in cache or DB. + throw new Error( + `getParentExecutionRequests: fork choice reports FULL parent ${parentBlockRootHex} but envelope is missing in cache and DB` + ); } async getExecutionPayloadEnvelope( diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index f58d42be4931..9801809d6d1f 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -187,6 +187,17 @@ export class PrepareNextSlotScheduler { }); } + if (ForkSeq[fork] >= ForkSeq.gloas) { + // Cutoff = slot of the parent of the block we'll actually build on (post-reorg). + // Steady state: cache holds just 2 entries — head (parent for next-slot production) + // and head.parent (proposer-boost-reorg fallback). Anything older is evicted. + const finalHead = this.chain.forkChoice.getBlockHexDefaultStatus(updatedHeadRoot); + const finalHeadParent = finalHead && this.chain.forkChoice.getBlockHexDefaultStatus(finalHead.parentRoot); + if (finalHeadParent) { + this.chain.seenPayloadEnvelopeInputCache.pruneBelow(finalHeadParent.slot); + } + } + if (!isStatePostBellatrix(updatedPrepareState)) { throw new Error("Expected Bellatrix state for payload attributes"); } diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index dbf50093e1e0..2dd6ef2a0a39 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -283,7 +283,7 @@ export async function produceBlockBody( // Determine parent execution requests for deferred processing (consensus-specs#5094) // If parent was FULL: include execution requests from its envelope // If parent was EMPTY: include empty execution requests - gloasBody.parentExecutionRequests = this.getParentExecutionRequests(parentBlock.blockRoot); + gloasBody.parentExecutionRequests = await this.getParentExecutionRequests(parentBlock.blockRoot); blockBody = gloasBody as AssembledBodyType; // Store execution payload data required to construct execution payload envelope later @@ -611,7 +611,7 @@ export async function prepareExecutionPayload( executionEngine: IExecutionEngine; config: ChainForkConfig; forkChoice?: IForkChoice; - seenPayloadEnvelopeInputCache?: {get(rootHex: string): {hasPayloadEnvelope(): boolean; getPayloadEnvelope(): gloas.SignedExecutionPayloadEnvelope} | undefined}; + getParentExecutionRequests?: (parentBlockRootHex: RootHex) => Promise; }, logger: Logger, fork: ForkPostBellatrix, @@ -627,7 +627,7 @@ export async function prepareExecutionPayload( // For Gloas: determine FULL vs EMPTY parent per spec's prepare_execution_payload // If extending FULL parent: apply parent payload to get correct withdrawals and use bid.blockHash // If EMPTY parent: use state.payloadExpectedWithdrawals and bid.parentBlockHash - if (isForkPostGloas(fork) && chain.forkChoice && chain.seenPayloadEnvelopeInputCache) { + if (isForkPostGloas(fork) && chain.forkChoice && chain.getParentExecutionRequests) { const gloasState = state as unknown as { latestExecutionPayloadBid: { slot: number; @@ -641,14 +641,13 @@ export async function prepareExecutionPayload( const parentRootHex = toRootHex(parentBlockRoot); if (chain.forkChoice.shouldExtendPayload(parentRootHex)) { - const payloadInput = chain.seenPayloadEnvelopeInputCache.get(parentRootHex); - if (payloadInput?.hasPayloadEnvelope()) { - // Build on FULL variant: apply parent payload to compute correct withdrawals, - // use bid.blockHash as EL head (per spec's prepare_execution_payload) - const gloasView = state as unknown as IBeaconStateViewGloas; - withdrawalsOverride = gloasView.getExpectedWithdrawalsForFullParent(payloadInput.getPayloadEnvelope()); - parentHash = gloasState.latestExecutionPayloadBid.blockHash; - } + // Build on FULL variant: fetch parent execution requests (cache → DB fallback), + // apply parent payload to compute correct withdrawals, use bid.blockHash as EL head + // (per spec's prepare_execution_payload) + const executionRequests = await chain.getParentExecutionRequests(parentRootHex); + const gloasView = state as unknown as IBeaconStateViewGloas; + withdrawalsOverride = gloasView.getExpectedWithdrawalsForFullParent(executionRequests); + parentHash = gloasState.latestExecutionPayloadBid.blockHash; } else { // EMPTY parent: use bid.parentBlockHash as the EL head parentHash = gloasState.latestExecutionPayloadBid.parentBlockHash; diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index e36147638061..338fa55d0066 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -1,6 +1,6 @@ import {CheckpointWithHex} from "@lodestar/fork-choice"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; -import {RootHex} from "@lodestar/types"; +import {RootHex, Slot} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; import {SerializedCache} from "../../util/serializedCache.js"; @@ -21,8 +21,20 @@ export type SeenPayloadEnvelopeInputModules = { /** * Cache for tracking PayloadEnvelopeInput instances, keyed by beacon block root. * - * Created during block import when a block is processed. - * Pruned on finalization and after payload is written to DB. + * Created during block import when a block is processed. Two pruning paths: + * - `prepareNextSlot` calls `pruneBelow(headParentSlot)` every slot once the head we'll build + * on is known. + * - `onFinalized` calls `pruneBelow(finalizedSlot)` on every finalization for bulk cleanup. + * + * Steady state (linear chain, healthy progression): the cache holds ~2 entries — the head + * (parent for next-slot production) and its parent (proposer-boost-reorg fallback). It can + * transiently hold more during forks, range-sync bursts, or when `prepareNextSlot` skips + * ticks; subsequent ticks settle it back. + * + * Consumers that miss the cache fall back to DB (`chain.getParentExecutionRequests` / + * `getExecutionPayloadEnvelope`). The authoritative view of "does this block / payload exist + * in the canonical chain" is `forkChoice` — this cache is a latency optimisation for the + * synchronous fast path, not a source of truth. */ export class SeenPayloadEnvelopeInput { private readonly chainEvents: ChainEventEmitter; @@ -58,16 +70,7 @@ export class SeenPayloadEnvelopeInput { } private onFinalized = (checkpoint: CheckpointWithHex): void => { - // Prune all entries with slot < finalized slot - const finalizedSlot = computeStartSlotAtEpoch(checkpoint.epoch); - let deletedCount = 0; - for (const [, input] of this.payloadInputs) { - if (input.slot < finalizedSlot) { - this.evictPayloadInput(input); - deletedCount++; - } - } - this.logger?.debug("SeenPayloadEnvelopeInput.onFinalized deleted cached entries", {deletedCount}); + this.pruneBelow(computeStartSlotAtEpoch(checkpoint.epoch)); }; add(props: CreateFromBlockProps): PayloadEnvelopeInput { @@ -88,17 +91,21 @@ export class SeenPayloadEnvelopeInput { return this.payloadInputs.get(blockRootHex)?.hasPayloadEnvelope() ?? false; } - prune(blockRootHex: RootHex): void { - const payloadInput = this.payloadInputs.get(blockRootHex); - if (payloadInput) { - this.evictPayloadInput(payloadInput); - } - } - size(): number { return this.payloadInputs.size; } + pruneBelow(slot: Slot): void { + let deletedCount = 0; + for (const [, input] of this.payloadInputs) { + if (input.slot < slot) { + this.evictPayloadInput(input); + deletedCount++; + } + } + this.logger?.debug("SeenPayloadEnvelopeInput.pruneBelow deleted entries", {slot, deletedCount}); + } + private evictPayloadInput(payloadInput: PayloadEnvelopeInput): void { this.serializedCache.delete(payloadInput.getSerializedCacheKeys()); this.payloadInputs.delete(payloadInput.blockRootHex); diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 831db7d8f0bd..234f6521b6c1 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -47,9 +47,10 @@ async function validateExecutionPayloadEnvelope( // [IGNORE] The node has not seen another valid // `SignedExecutionPayloadEnvelope` for this block root from this builder. + // Fork choice flips to PayloadStatus.FULL during envelope import, so it is the authoritative + // duplicate-detection signal. const envelopeBlock = chain.forkChoice.getBlockHex(blockRootHex, PayloadStatus.FULL); - const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); - if (envelopeBlock || payloadInput?.hasPayloadEnvelope()) { + if (envelopeBlock) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, blockRoot: blockRootHex, @@ -57,6 +58,7 @@ async function validateExecutionPayloadEnvelope( }); } + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); if (!payloadInput) { // PayloadEnvelopeInput should have been created during block import throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 1353fa3e95f1..fb30de9dd5be 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -793,13 +793,13 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { return new BeaconStateView(newState); } - getExpectedWithdrawalsForFullParent(envelope: gloas.SignedExecutionPayloadEnvelope): capella.Withdrawal[] { + getExpectedWithdrawalsForFullParent(executionRequests: electra.ExecutionRequests): capella.Withdrawal[] { const fork = this.config.getForkSeq(this.cachedState.slot); if (!isForkPostGloas(this.config.getForkName(this.cachedState.slot))) { throw Error("getExpectedWithdrawalsForFullParent is only available for gloas+ forks"); } const stateCopy = this.cachedState.clone(true) as CachedBeaconStateGloas; - applyParentExecutionPayload(stateCopy, stateCopy.latestExecutionPayloadBid, envelope.message.executionRequests); + applyParentExecutionPayload(stateCopy, stateCopy.latestExecutionPayloadBid, executionRequests); const {expectedWithdrawals} = getExpectedWithdrawals(fork, stateCopy); return expectedWithdrawals; } diff --git a/packages/state-transition/src/stateView/interface.ts b/packages/state-transition/src/stateView/interface.ts index 56af9b03466f..58d6da32c17b 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -254,7 +254,7 @@ export interface IBeaconStateViewGloas extends IBeaconStateViewFulu { * Clones the state, applies parent payload effects, then computes withdrawals. * Used by prepare_execution_payload when building on FULL parent. */ - getExpectedWithdrawalsForFullParent(envelope: gloas.SignedExecutionPayloadEnvelope): capella.Withdrawal[]; + getExpectedWithdrawalsForFullParent(executionRequests: electra.ExecutionRequests): capella.Withdrawal[]; } /**