From 42b9998941670c377da2c006aea71987e379c730 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 30 Jul 2026 14:52:35 +0100 Subject: [PATCH 1/4] fix: use parent block slot for attestation payload availability --- .../opPools/aggregatedAttestationPool.ts | 4 ++- .../test/spec/presets/operations.test.ts | 11 ++++++-- packages/state-transition/src/block/index.ts | 7 +++--- .../src/block/processAttestations.ts | 14 ++++++++--- .../src/block/processAttestationsAltair.ts | 16 ++++++++---- .../src/block/processExecutionPayloadBid.ts | 7 ++++-- .../src/block/processOperations.ts | 7 +++--- .../src/rewards/blockRewards.ts | 25 +++++++++++++++---- .../src/slot/upgradeStateToAltair.ts | 1 + 9 files changed, 68 insertions(+), 24 deletions(-) diff --git a/packages/beacon-node/src/chain/opPools/aggregatedAttestationPool.ts b/packages/beacon-node/src/chain/opPools/aggregatedAttestationPool.ts index 72be5eec7fe2..76543fa57459 100644 --- a/packages/beacon-node/src/chain/opPools/aggregatedAttestationPool.ts +++ b/packages/beacon-node/src/chain/opPools/aggregatedAttestationPool.ts @@ -234,6 +234,7 @@ export class AggregatedAttestationPool { const stateEpoch = state.epoch; const statePrevEpoch = stateEpoch - 1; const rootCache = new RootCache(state); + const gloasState = isStatePostGloas(state) ? state : null; const notSeenValidatorsFn = getNotSeenValidatorsFn(this.config, shufflingCache, state); const validateAttestationDataFn = getValidateAttestationDataFn(forkChoice, state); @@ -361,7 +362,8 @@ export class AggregatedAttestationPool { inclusionDistance, stateEpoch, rootCache, - isStatePostGloas(state) ? state.executionPayloadAvailability : null + gloasState?.executionPayloadAvailability ?? null, + gloasState?.latestExecutionPayloadBid.slot ?? null ); const weight = diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index 30f11711aec8..f3811074e160 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} from "@lodestar/params"; +import {ACTIVE_PRESET, ForkName, ForkSeq} from "@lodestar/params"; import {InputType} from "@lodestar/spec-test-util"; import { BeaconStateAllForks, @@ -38,7 +38,14 @@ const syncAggregate: BlockProcessFn = ( const operationFns: Record> = { attestation: (state, testCase: {attestation: phase0.Attestation}) => { const fork = state.config.getForkSeq(state.slot); - blockFns.processAttestations(fork, state, [testCase.attestation]); + blockFns.processAttestations( + fork, + state, + [testCase.attestation], + true, + undefined, + fork >= ForkSeq.gloas ? (state as CachedBeaconStateGloas).latestExecutionPayloadBid.slot : null + ); }, attester_slashing: (state, testCase: BaseSpecTest & {attester_slashing: AttesterSlashing}) => { diff --git a/packages/state-transition/src/block/index.ts b/packages/state-transition/src/block/index.ts index ca5114339f01..fc2116db1a1f 100644 --- a/packages/state-transition/src/block/index.ts +++ b/packages/state-transition/src/block/index.ts @@ -1,5 +1,5 @@ import {ForkPostGloas, ForkSeq} from "@lodestar/params"; -import {BeaconBlock, BlindedBeaconBlock, altair, capella} from "@lodestar/types"; +import {BeaconBlock, BlindedBeaconBlock, Slot, altair, capella} from "@lodestar/types"; import {BeaconStateTransitionMetrics} from "../metrics.js"; import { CachedBeaconStateAllForks, @@ -86,8 +86,9 @@ export function processBlock( processExecutionPayload(fork, state as CachedBeaconStateBellatrix, block.body, externalData); } + let parentSlot: Slot | null = null; if (fork >= ForkSeq.gloas) { - processExecutionPayloadBid( + parentSlot = processExecutionPayloadBid( state as CachedBeaconStateGloas, (block as BeaconBlock).body.signedExecutionPayloadBid ); @@ -95,7 +96,7 @@ export function processBlock( processRandao(state, block, verifySignatures); processEth1Data(state, block.body.eth1Data); - processOperations(fork, state, block.body, opts, metrics); + processOperations(fork, state, block.body, opts, metrics, parentSlot); if (fork >= ForkSeq.altair) { processSyncAggregate(state, block as altair.BeaconBlock, verifySignatures); } diff --git a/packages/state-transition/src/block/processAttestations.ts b/packages/state-transition/src/block/processAttestations.ts index b603b81c562a..43e00bd59b10 100644 --- a/packages/state-transition/src/block/processAttestations.ts +++ b/packages/state-transition/src/block/processAttestations.ts @@ -1,5 +1,5 @@ import {ForkSeq} from "@lodestar/params"; -import {Attestation} from "@lodestar/types"; +import {Attestation, Slot} from "@lodestar/types"; import {BeaconStateTransitionMetrics} from "../metrics.js"; import {CachedBeaconStateAllForks, CachedBeaconStateAltair, CachedBeaconStatePhase0} from "../types.js"; import {processAttestationPhase0} from "./processAttestationPhase0.js"; @@ -13,13 +13,21 @@ export function processAttestations( state: CachedBeaconStateAllForks, attestations: Attestation[], verifySignatures = true, - metrics?: BeaconStateTransitionMetrics | null + metrics?: BeaconStateTransitionMetrics | null, + parentSlot: Slot | null = null ): void { if (fork === ForkSeq.phase0) { for (const attestation of attestations) { processAttestationPhase0(state as CachedBeaconStatePhase0, attestation, verifySignatures); } } else { - processAttestationsAltair(fork, state as CachedBeaconStateAltair, attestations, verifySignatures, metrics); + processAttestationsAltair( + fork, + state as CachedBeaconStateAltair, + attestations, + verifySignatures, + metrics, + parentSlot + ); } } diff --git a/packages/state-transition/src/block/processAttestationsAltair.ts b/packages/state-transition/src/block/processAttestationsAltair.ts index 5622be01b776..4cf5afdd3ee7 100644 --- a/packages/state-transition/src/block/processAttestationsAltair.ts +++ b/packages/state-transition/src/block/processAttestationsAltair.ts @@ -14,7 +14,7 @@ import { TIMELY_TARGET_WEIGHT, WEIGHT_DENOMINATOR, } from "@lodestar/params"; -import {Attestation, Epoch, phase0} from "@lodestar/types"; +import {Attestation, Epoch, Slot, phase0} from "@lodestar/types"; import {byteArrayEquals, intSqrt} from "@lodestar/utils"; import {BeaconStateTransitionMetrics} from "../metrics.js"; import {getAttestationWithIndicesSignatureSet} from "../signatureSets/indexedAttestation.js"; @@ -38,7 +38,8 @@ export function processAttestationsAltair( state: CachedBeaconStateAltair | CachedBeaconStateGloas, attestations: Attestation[], verifySignature = true, - metrics?: BeaconStateTransitionMetrics | null + metrics?: BeaconStateTransitionMetrics | null, + parentSlot: Slot | null = null ): void { const {epochCtx} = state; const {effectiveBalanceIncrements} = epochCtx; @@ -82,7 +83,8 @@ export function processAttestationsAltair( stateSlot - data.slot, epochCtx.epoch, rootCache, - fork >= ForkSeq.gloas ? (state as CachedBeaconStateGloas).executionPayloadAvailability : null + fork >= ForkSeq.gloas ? (state as CachedBeaconStateGloas).executionPayloadAvailability : null, + parentSlot ); // For each participant, update their participation @@ -179,7 +181,8 @@ export function getAttestationParticipationStatus( inclusionDelay: number, currentEpoch: Epoch, rootCache: RootCache, - executionPayloadAvailability: BitArray | null + executionPayloadAvailability: BitArray | null, + parentSlot: Slot | null ): {flags: number; isSameSlotAttestation: boolean} { const justifiedCheckpoint = data.target.epoch === currentEpoch ? rootCache.currentJustifiedCheckpoint : rootCache.previousJustifiedCheckpoint; @@ -221,13 +224,16 @@ export function getAttestationParticipationStatus( if (executionPayloadAvailability === null) { throw new Error("Must supply executionPayloadAvailability post-gloas"); } + if (parentSlot === null) { + throw new Error("Must supply parentSlot post-gloas"); + } if (data.index !== 0 && data.index !== 1) { throw new Error(`data index must be 0 or 1 index=${data.index}`); } isMatchingPayload = - Boolean(data.index) === executionPayloadAvailability.get(data.slot % SLOTS_PER_HISTORICAL_ROOT); + Boolean(data.index) === executionPayloadAvailability.get(parentSlot % SLOTS_PER_HISTORICAL_ROOT); } isMatchingHead = isMatchingHead && isMatchingPayload; diff --git a/packages/state-transition/src/block/processExecutionPayloadBid.ts b/packages/state-transition/src/block/processExecutionPayloadBid.ts index 2ccf86c536ea..ddc196a787e0 100644 --- a/packages/state-transition/src/block/processExecutionPayloadBid.ts +++ b/packages/state-transition/src/block/processExecutionPayloadBid.ts @@ -1,6 +1,6 @@ import {PublicKey, Signature, verify} from "@chainsafe/blst"; import {BUILDER_INDEX_SELF_BUILD, GENESIS_SLOT, PAYLOAD_BUILDER_VERSION, SLOTS_PER_EPOCH} from "@lodestar/params"; -import {gloas, ssz} from "@lodestar/types"; +import {Slot, gloas, ssz} from "@lodestar/types"; import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; import {G2_POINT_AT_INFINITY} from "../constants/constants.js"; import {getExecutionPayloadBidSigningRoot} from "../signatureSets/executionPayloadBid.js"; @@ -11,7 +11,7 @@ import {getBlockRootAtSlot, getCurrentEpoch, getRandaoMix} from "../util/index.j export function processExecutionPayloadBid( state: CachedBeaconStateGloas, signedBid: gloas.SignedExecutionPayloadBid -): void { +): Slot { const bid = signedBid.message; const {builderIndex, value: amount} = bid; @@ -99,7 +99,10 @@ export function processExecutionPayloadBid( state.builderPendingPayments.set(SLOTS_PER_EPOCH + (bid.slot % SLOTS_PER_EPOCH), pendingPaymentView); } + const parentSlot = state.latestExecutionPayloadBid.slot; state.latestExecutionPayloadBid = ssz.gloas.ExecutionPayloadBid.toViewDU(bid); + + return parentSlot; } function verifyExecutionPayloadBidSignature( diff --git a/packages/state-transition/src/block/processOperations.ts b/packages/state-transition/src/block/processOperations.ts index 06f3137e6eef..e623038fae56 100644 --- a/packages/state-transition/src/block/processOperations.ts +++ b/packages/state-transition/src/block/processOperations.ts @@ -7,7 +7,7 @@ import { MAX_PROPOSER_SLASHINGS, MAX_VOLUNTARY_EXITS, } from "@lodestar/params"; -import {BeaconBlockBody, capella, electra, gloas} from "@lodestar/types"; +import {BeaconBlockBody, Slot, capella, electra, gloas} from "@lodestar/types"; import {BeaconStateTransitionMetrics} from "../metrics.js"; import { CachedBeaconStateAllForks, @@ -45,7 +45,8 @@ export function processOperations( state: CachedBeaconStateAllForks, body: BeaconBlockBody, opts: ProcessBlockOpts = {verifySignatures: true}, - metrics?: BeaconStateTransitionMetrics | null + metrics?: BeaconStateTransitionMetrics | null, + parentSlot: Slot | null = null ): void { if (fork >= ForkSeq.gloas) { assertGloasOperationLimits(body as gloas.BeaconBlockBody); @@ -67,7 +68,7 @@ export function processOperations( processAttesterSlashing(fork, state, attesterSlashing, opts.verifySignatures); } - processAttestations(fork, state, body.attestations, opts.verifySignatures, metrics); + processAttestations(fork, state, body.attestations, opts.verifySignatures, metrics, parentSlot); for (const deposit of body.deposits) { processDeposit(fork, state, deposit); diff --git a/packages/state-transition/src/rewards/blockRewards.ts b/packages/state-transition/src/rewards/blockRewards.ts index d455595aae33..c1d94e55d265 100644 --- a/packages/state-transition/src/rewards/blockRewards.ts +++ b/packages/state-transition/src/rewards/blockRewards.ts @@ -4,11 +4,17 @@ import { WHISTLEBLOWER_REWARD_QUOTIENT, WHISTLEBLOWER_REWARD_QUOTIENT_ELECTRA, isForkPostElectra, + isForkPostGloas, } from "@lodestar/params"; -import {BeaconBlock, altair, phase0, rewards} from "@lodestar/types"; +import {BeaconBlock, Slot, altair, phase0, rewards} from "@lodestar/types"; import {processAttestationsAltair} from "../block/processAttestationsAltair.js"; import {RewardCache} from "../cache/rewardCache.js"; -import {CachedBeaconStateAllForks, CachedBeaconStateAltair, CachedBeaconStatePhase0} from "../cache/stateCache.js"; +import { + CachedBeaconStateAllForks, + CachedBeaconStateAltair, + CachedBeaconStateGloas, + CachedBeaconStatePhase0, +} from "../cache/stateCache.js"; import {getAttesterSlashableIndices} from "../util/attestation.js"; type SubRewardValue = number; // All reward values should be integer @@ -36,10 +42,18 @@ export async function computeBlockRewards( let syncAggregateReward = cachedSyncAggregateReward; if (blockAttestationReward === 0) { + const parentSlot = isForkPostGloas(fork) + ? (preState as CachedBeaconStateGloas).latestExecutionPayloadBid.slot + : null; blockAttestationReward = fork === ForkName.phase0 ? computeBlockAttestationRewardPhase0(block as phase0.BeaconBlock, preState as CachedBeaconStatePhase0) - : computeBlockAttestationRewardAltair(config, block as altair.BeaconBlock, preState as CachedBeaconStateAltair); + : computeBlockAttestationRewardAltair( + config, + block as altair.BeaconBlock, + preState as CachedBeaconStateAltair, + parentSlot + ); } if (syncAggregateReward === 0) { @@ -79,12 +93,13 @@ function computeBlockAttestationRewardPhase0( function computeBlockAttestationRewardAltair( config: BeaconConfig, block: altair.BeaconBlock, - preState: CachedBeaconStateAltair + preState: CachedBeaconStateAltair, + parentSlot: Slot | null ): SubRewardValue { const fork = config.getForkSeq(block.slot); const {attestations} = block.body; - processAttestationsAltair(fork, preState, attestations, false); + processAttestationsAltair(fork, preState, attestations, false, undefined, parentSlot); return preState.proposerRewards.attestations; } diff --git a/packages/state-transition/src/slot/upgradeStateToAltair.ts b/packages/state-transition/src/slot/upgradeStateToAltair.ts index a7d5e35339de..a3f305e5d31e 100644 --- a/packages/state-transition/src/slot/upgradeStateToAltair.ts +++ b/packages/state-transition/src/slot/upgradeStateToAltair.ts @@ -137,6 +137,7 @@ function translateParticipation( attestation.inclusionDelay, epochCtx.epoch, rootCache, + null, null ); From efc6acc0334a76bb5ba29faea8fe8d86aa5e0e78 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 30 Jul 2026 15:52:20 +0100 Subject: [PATCH 2/4] refactor --- packages/beacon-node/test/spec/presets/operations.test.ts | 2 -- packages/state-transition/src/block/index.ts | 2 +- .../state-transition/src/block/processAttestations.ts | 8 ++++---- .../src/block/processAttestationsAltair.ts | 4 ++-- packages/state-transition/src/block/processOperations.ts | 6 +++--- packages/state-transition/src/rewards/blockRewards.ts | 2 +- .../test/perf/block/processAttestation.test.ts | 1 + 7 files changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index f3811074e160..1c765d822866 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -42,8 +42,6 @@ const operationFns: Record> = fork, state, [testCase.attestation], - true, - undefined, fork >= ForkSeq.gloas ? (state as CachedBeaconStateGloas).latestExecutionPayloadBid.slot : null ); }, diff --git a/packages/state-transition/src/block/index.ts b/packages/state-transition/src/block/index.ts index fc2116db1a1f..2f6cffccfb5f 100644 --- a/packages/state-transition/src/block/index.ts +++ b/packages/state-transition/src/block/index.ts @@ -96,7 +96,7 @@ export function processBlock( processRandao(state, block, verifySignatures); processEth1Data(state, block.body.eth1Data); - processOperations(fork, state, block.body, opts, metrics, parentSlot); + processOperations(fork, state, block.body, parentSlot, opts, metrics); if (fork >= ForkSeq.altair) { processSyncAggregate(state, block as altair.BeaconBlock, verifySignatures); } diff --git a/packages/state-transition/src/block/processAttestations.ts b/packages/state-transition/src/block/processAttestations.ts index 43e00bd59b10..01d237888575 100644 --- a/packages/state-transition/src/block/processAttestations.ts +++ b/packages/state-transition/src/block/processAttestations.ts @@ -12,9 +12,9 @@ export function processAttestations( fork: ForkSeq, state: CachedBeaconStateAllForks, attestations: Attestation[], + parentSlot: Slot | null, verifySignatures = true, - metrics?: BeaconStateTransitionMetrics | null, - parentSlot: Slot | null = null + metrics?: BeaconStateTransitionMetrics | null ): void { if (fork === ForkSeq.phase0) { for (const attestation of attestations) { @@ -25,9 +25,9 @@ export function processAttestations( fork, state as CachedBeaconStateAltair, attestations, + parentSlot, verifySignatures, - metrics, - parentSlot + metrics ); } } diff --git a/packages/state-transition/src/block/processAttestationsAltair.ts b/packages/state-transition/src/block/processAttestationsAltair.ts index 4cf5afdd3ee7..050c826d99c1 100644 --- a/packages/state-transition/src/block/processAttestationsAltair.ts +++ b/packages/state-transition/src/block/processAttestationsAltair.ts @@ -37,9 +37,9 @@ export function processAttestationsAltair( fork: ForkSeq, state: CachedBeaconStateAltair | CachedBeaconStateGloas, attestations: Attestation[], + parentSlot: Slot | null, verifySignature = true, - metrics?: BeaconStateTransitionMetrics | null, - parentSlot: Slot | null = null + metrics?: BeaconStateTransitionMetrics | null ): void { const {epochCtx} = state; const {effectiveBalanceIncrements} = epochCtx; diff --git a/packages/state-transition/src/block/processOperations.ts b/packages/state-transition/src/block/processOperations.ts index e623038fae56..0f41ece3cb56 100644 --- a/packages/state-transition/src/block/processOperations.ts +++ b/packages/state-transition/src/block/processOperations.ts @@ -44,9 +44,9 @@ export function processOperations( fork: ForkSeq, state: CachedBeaconStateAllForks, body: BeaconBlockBody, + parentSlot: Slot | null, opts: ProcessBlockOpts = {verifySignatures: true}, - metrics?: BeaconStateTransitionMetrics | null, - parentSlot: Slot | null = null + metrics?: BeaconStateTransitionMetrics | null ): void { if (fork >= ForkSeq.gloas) { assertGloasOperationLimits(body as gloas.BeaconBlockBody); @@ -68,7 +68,7 @@ export function processOperations( processAttesterSlashing(fork, state, attesterSlashing, opts.verifySignatures); } - processAttestations(fork, state, body.attestations, opts.verifySignatures, metrics, parentSlot); + processAttestations(fork, state, body.attestations, parentSlot, opts.verifySignatures, metrics); for (const deposit of body.deposits) { processDeposit(fork, state, deposit); diff --git a/packages/state-transition/src/rewards/blockRewards.ts b/packages/state-transition/src/rewards/blockRewards.ts index c1d94e55d265..e19773d9ca1a 100644 --- a/packages/state-transition/src/rewards/blockRewards.ts +++ b/packages/state-transition/src/rewards/blockRewards.ts @@ -99,7 +99,7 @@ function computeBlockAttestationRewardAltair( const fork = config.getForkSeq(block.slot); const {attestations} = block.body; - processAttestationsAltair(fork, preState, attestations, false, undefined, parentSlot); + processAttestationsAltair(fork, preState, attestations, parentSlot, false); return preState.proposerRewards.attestations; } diff --git a/packages/state-transition/test/perf/block/processAttestation.test.ts b/packages/state-transition/test/perf/block/processAttestation.test.ts index 733c9891c3af..f52e29ce39fa 100644 --- a/packages/state-transition/test/perf/block/processAttestation.test.ts +++ b/packages/state-transition/test/perf/block/processAttestation.test.ts @@ -76,6 +76,7 @@ describe("altair processAttestation", () => { state.config.getForkSeq(state.slot), state as CachedBeaconStateAltair, attestations, + null, false ); state.commit(); From ef554e74f7304b2741a96dc8044651d1519f0727 Mon Sep 17 00:00:00 2001 From: twoeths Date: Fri, 31 Jul 2026 14:31:27 +0700 Subject: [PATCH 3/4] fix: pass parentStateWithPayload for produceCommonBlockBody --- .../src/api/impl/validator/index.ts | 56 +++++++++++ packages/beacon-node/src/chain/chain.ts | 42 ++------ .../chain/produceBlock/produceBlockBody.ts | 98 +++++++------------ .../test/mocks/mockedBeaconChain.ts | 1 + .../produceBlock/produceBlockBody.test.ts | 8 +- .../api/impl/validator/produceBlockV3.test.ts | 8 +- .../api/impl/validator/produceBlockV4.test.ts | 9 ++ 7 files changed, 124 insertions(+), 98 deletions(-) diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 371ab178c14e..84da2710d0c6 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -377,9 +377,13 @@ export function getValidatorApi( { commonBlockBodyPromise, parentBlock, + parentState, + parentStateWithPayload, }: Omit & { commonBlockBodyPromise: Promise; parentBlock: ProtoBlock; + parentState: IBeaconStateView; + parentStateWithPayload: IBeaconStateView; } ): Promise { const version = config.getForkName(slot); @@ -414,6 +418,8 @@ export function getValidatorApi( randaoReveal, graffiti, commonBlockBodyPromise, + parentState, + parentStateWithPayload, }); metrics?.blockProductionSuccess.inc({source}); @@ -446,9 +452,13 @@ export function getValidatorApi( strictFeeRecipientCheck, commonBlockBodyPromise, parentBlock, + parentState, + parentStateWithPayload, }: Omit & { commonBlockBodyPromise: Promise; parentBlock: ProtoBlock; + parentState: IBeaconStateView; + parentStateWithPayload: IBeaconStateView; } ): Promise { const source = ProducedBlockSource.engine; @@ -464,6 +474,8 @@ export function getValidatorApi( graffiti, feeRecipient, commonBlockBodyPromise, + parentState, + parentStateWithPayload, }); const version = config.getForkName(block.slot); if (strictFeeRecipientCheck && feeRecipient && isForkPostBellatrix(version)) { @@ -548,6 +560,16 @@ export function getValidatorApi( notOnOutOfRangeData(parentBlockRoot); metrics?.blockProductionSlotDelta.set(slot - parentSlot); + // Fetch the production state once and thread it to all downstream production calls. Pre-gloas has no + // parent execution payload, so the applied state is the same view as parentState. + const parentState = await chain.regen.getBlockSlotState( + parentBlock, + slot, + {dontTransferCache: true}, + RegenCaller.produceBlock + ); + const parentStateWithPayload = parentState; + const fork = config.getForkName(slot); const isBuilderEnabled = @@ -591,6 +613,8 @@ export function getValidatorApi( strictFeeRecipientCheck: false, commonBlockBodyPromise, parentBlock, + parentState, + parentStateWithPayload, }) : Promise.reject(new Error("Builder disabled")); @@ -599,6 +623,8 @@ export function getValidatorApi( strictFeeRecipientCheck, commonBlockBodyPromise, parentBlock, + parentState, + parentStateWithPayload, }).then((engineBlock) => { // Once the engine returns a block, in the event of either: // - suspected builder censorship @@ -642,6 +668,8 @@ export function getValidatorApi( parentBlock, randaoReveal, graffiti: graffitiBytes, + parentState, + parentStateWithPayload, }) .then((commonBlockBody) => { deferredCommonBlockBody.resolve(commonBlockBody); @@ -910,11 +938,36 @@ export function getValidatorApi( : {}), }; + // Fetch the production state once and apply the parent execution payload once here, so all + // downstream consumers share a single state: the un-applied `parentState` (for `computeNewStateRoot`) + // and the applied `parentStateWithPayload` (for attestation scoring and execution-payload prep). + const parentState = await chain.regen.getBlockSlotState( + parentBlock, + slot, + {dontTransferCache: true}, + RegenCaller.produceBlock + ); + // Fetch the parent's execution requests once here and thread them via blockAttributes: they are + // used both to apply the parent payload below and as the block's `parentExecutionRequests` field + // (so both are guaranteed consistent). Only needed when building on FULL; empty parent has none. + const parentExecutionRequests = + isBuildingOnFull && isStatePostGloas(parentState) + ? await chain.getParentExecutionRequests(parentSlot, parentBlockRootHex) + : ssz.gloas.ExecutionRequests.defaultValue(); + // When building on EMPTY there is nothing to apply, so it stays the same view as parentState + // (the availability bit for parentSlot stays false, which is correct). + const parentStateWithPayload = + isBuildingOnFull && isStatePostGloas(parentState) + ? parentState.withParentPayloadApplied(parentExecutionRequests) + : parentState; + const commonBlockBodyPromise = chain.produceCommonBlockBody({ slot, parentBlock, randaoReveal, graffiti: graffitiBytes, + parentState, + parentStateWithPayload, }); const baseAttrs = { @@ -924,6 +977,9 @@ export function getValidatorApi( graffiti: graffitiBytes, feeRecipient, commonBlockBodyPromise, + parentState, + parentStateWithPayload, + parentExecutionRequests, }; metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine}); diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 8be3c2c644f5..da30e9283002 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -1031,18 +1031,10 @@ export class BeaconChain implements IBeaconChain { } async produceCommonBlockBody(blockAttributes: BlockAttributes): Promise { - const {slot, parentBlock} = blockAttributes; - const state = await this.regen.getBlockSlotState( - parentBlock, - slot, - {dontTransferCache: true}, - RegenCaller.produceBlock - ); - // TODO: To avoid breaking changes for metric define this attribute const blockType = BlockType.Full; - return produceCommonBlockBody.call(this, blockType, state, blockAttributes); + return produceCommonBlockBody.call(this, blockType, blockAttributes); } produceBlock(blockAttributes: BlockAttributes & {commonBlockBodyPromise: Promise}): Promise<{ @@ -1064,44 +1056,24 @@ export class BeaconChain implements IBeaconChain { async produceBlockWrapper( blockType: T, - { - randaoReveal, - graffiti, - slot, - feeRecipient, - commonBlockBodyPromise, - parentBlock, - builderBid, - }: BlockAttributes & {commonBlockBodyPromise: Promise} + blockAttributes: BlockAttributes & {commonBlockBodyPromise: Promise} ): Promise<{ block: AssembledBlockType; executionPayloadValue: Wei; consensusBlockValue: Wei; shouldOverrideBuilder?: boolean; }> { - const state = await this.regen.getBlockSlotState( - parentBlock, - slot, - {dontTransferCache: true}, - RegenCaller.produceBlock - ); - const proposerIndex = state.getBeaconProposer(slot); + const {slot, parentBlock, parentState} = blockAttributes; + const proposerIndex = parentState.getBeaconProposer(slot); const proposerPubKey = this.pubkeyCache.getOrThrow(proposerIndex).toBytes(); const {body, produceResult, executionPayloadValue, shouldOverrideBuilder} = await produceBlockBody.call( this, blockType, - state, { - randaoReveal, - graffiti, - slot, - feeRecipient, - parentBlock, + ...blockAttributes, proposerIndex, proposerPubKey, - commonBlockBodyPromise, - builderBid, } ); @@ -1126,7 +1098,9 @@ export class BeaconChain implements IBeaconChain { body, } as AssembledBlockType; - const {newStateRoot, proposerReward} = computeNewStateRoot(this.metrics, state, block); + // `parentState` is the un-applied production state; `computeNewStateRoot` re-applies the parent + // payload during state transition, so it must not receive the already-applied state + const {newStateRoot, proposerReward} = computeNewStateRoot(this.metrics, parentState, block); block.stateRoot = newStateRoot; const blockRoot = produceResult.type === BlockType.Full diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 25cea68f1d3b..7f8b739c5485 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -91,6 +91,12 @@ export type BlockAttributes = { graffiti: Bytes32; slot: Slot; parentBlock: ProtoBlock; + // the state of parentBlock dialing to slot + parentState: IBeaconStateView; + // parentStateWithPayload is the same to parentState for pre-gloas or if building on EMPTY + parentStateWithPayload: IBeaconStateView; + // Parent's execution requests, undefined pre-gloas. + parentExecutionRequests?: gloas.ExecutionRequests; feeRecipient?: string; /** When provided, build block with this builder bid instead of a self-build bid */ builderBid?: gloas.SignedExecutionPayloadBid; @@ -153,32 +159,9 @@ export type ProduceResult = | ProduceFullPhase0 | ProduceBlinded; -/** - * Drop voluntary exits that `parent_execution_requests` have invalidated (e.g. a withdrawal - * request initiating an exit on the same validator). Op pool selected against the unapplied - * state, so re-validate against the post-apply state to avoid producing an invalid block. - * - * `getStateAfterParentPayload` is a thunk so the post-apply state is only materialized when - * actually needed (i.e. when extending the parent payload and there are exits to filter). - */ -function maybeFilterInvalidatedVoluntaryExits( - commonBlockBody: CommonBlockBody, - isExtendingPayload: boolean, - getStateAfterParentPayload: () => IBeaconStateViewBellatrix -): CommonBlockBody["voluntaryExits"] { - if (!isExtendingPayload || commonBlockBody.voluntaryExits.length === 0) { - return commonBlockBody.voluntaryExits; - } - const state = getStateAfterParentPayload(); - return commonBlockBody.voluntaryExits.filter((signedVoluntaryExit) => - state.isValidVoluntaryExit(signedVoluntaryExit, false) - ); -} - export async function produceBlockBody( this: BeaconChain, blockType: T, - currentState: IBeaconStateView, blockAttr: BlockAttributes & { proposerIndex: ValidatorIndex; proposerPubKey: BLSPubkey; @@ -198,6 +181,9 @@ export async function produceBlockBody( proposerPubKey, commonBlockBodyPromise, builderBid, + parentState, + parentStateWithPayload, + parentExecutionRequests, } = blockAttr; let executionPayloadValue: Wei; let blockBody: AssembledBodyType; @@ -219,17 +205,17 @@ export async function produceBlockBody( this.logger.verbose("Producing beacon block body", logMeta); if (builderBid !== undefined) { - if (!isStatePostGloas(currentState)) { - throw new Error("Expected Gloas state for builder bid block production"); + if (!isStatePostGloas(parentState)) { + throw new Error(`Expected Gloas state for builder bid block production slot=${blockSlot}`); + } + if (parentExecutionRequests === undefined) { + throw new Error(`Expected parentExecutionRequests for gloas block production slot=${blockSlot}`); } const isExtendingPayload = byteArrayEquals( builderBid.message.parentBlockHash, - currentState.latestExecutionPayloadBid.blockHash + parentState.latestExecutionPayloadBid.blockHash ); - const parentExecutionRequests = isExtendingPayload - ? await this.getParentExecutionRequests(parentBlock.slot, parentBlock.blockRoot) - : ssz.gloas.ExecutionRequests.defaultValue(); executionPayloadValue = BigInt(builderBid.message.value) * GWEI_TO_WEI; const commonBlockBody = await commonBlockBodyPromise; @@ -239,10 +225,9 @@ export async function produceBlockBody( parentBlock.blockRoot, blockSlot - 1 ); + // parentExecutionRequests fetched once by the entrypoint (isExtendingPayload aligns with the + // build-on-full decision used to select this bid; default value when building on an empty parent) gloasBody.parentExecutionRequests = parentExecutionRequests; - gloasBody.voluntaryExits = maybeFilterInvalidatedVoluntaryExits(commonBlockBody, isExtendingPayload, () => - currentState.withParentPayloadApplied(parentExecutionRequests) - ); blockBody = gloasBody as AssembledBodyType; this.logger.verbose("Produced block with builder bid", { @@ -255,8 +240,11 @@ export async function produceBlockBody( isExtendingPayload, }); } else if (isForkPostGloas(fork)) { - if (!isStatePostGloas(currentState)) { - throw new Error("Expected Gloas state for Gloas block production"); + if (!isStatePostGloas(parentState)) { + throw new Error(`Expected Gloas state for Gloas block production slot=${blockSlot}`); + } + if (parentExecutionRequests === undefined) { + throw new Error(`Expected parentExecutionRequests for gloas block production slot=${blockSlot}`); } // TODO GLOAS: support non self-building here, the block type differentiation between @@ -273,20 +261,15 @@ export async function produceBlockBody( // Get execution payload from EL let parentBlockHash: Bytes32; - let parentExecutionRequests: gloas.ExecutionRequests; - // Apply parent payload once here as it's reused by EL prep and voluntary exit filtering below - let stateAfterParentPayload: IBeaconStateViewBellatrix = currentState; + const stateAfterParentPayload = parentStateWithPayload as IBeaconStateViewBellatrix; // Spec: should_build_on_full(store, head). `parentBlock` is the proposer's head // (set by chain.getProposerHead(slot)). Returns false when the PTC majority signalled // the blob data is not available or the payload was not timely, forcing a build on EMPTY (reorg). const isBuildingOnFull = this.forkChoice.shouldBuildOnFull(parentBlock, blockSlot); if (isBuildingOnFull) { - parentBlockHash = currentState.latestExecutionPayloadBid.blockHash; - parentExecutionRequests = await this.getParentExecutionRequests(parentBlock.slot, parentBlock.blockRoot); - stateAfterParentPayload = currentState.withParentPayloadApplied(parentExecutionRequests); + parentBlockHash = parentState.latestExecutionPayloadBid.blockHash; } else { - parentBlockHash = currentState.latestExecutionPayloadBid.parentBlockHash; - parentExecutionRequests = ssz.gloas.ExecutionRequests.defaultValue(); + parentBlockHash = parentState.latestExecutionPayloadBid.parentBlockHash; } const prepareRes = await prepareExecutionPayload( this, @@ -343,7 +326,7 @@ export async function produceBlockBody( parentBlockHash, parentBlockRoot, blockHash: executionPayload.blockHash, - prevRandao: currentState.getRandaoMix(currentState.epoch), + prevRandao: parentState.getRandaoMix(parentState.epoch), feeRecipient: executionPayload.feeRecipient, gasLimit: executionPayload.gasLimit, builderIndex: BUILDER_INDEX_SELF_BUILD, @@ -365,12 +348,8 @@ export async function produceBlockBody( parentBlock.blockRoot, blockSlot - 1 ); + // parentExecutionRequests fetched once by the entrypoint (default value when building on an empty parent) gloasBody.parentExecutionRequests = parentExecutionRequests; - gloasBody.voluntaryExits = maybeFilterInvalidatedVoluntaryExits( - commonBlockBody, - isBuildingOnFull, - () => stateAfterParentPayload - ); blockBody = gloasBody as AssembledBodyType; // Store execution payload data required to construct execution payload envelope later @@ -400,7 +379,7 @@ export async function produceBlockBody( shouldOverrideBuilder, }); } else if (isForkPostBellatrix(fork)) { - if (!isStatePostBellatrix(currentState)) { + if (!isStatePostBellatrix(parentState)) { throw new Error("Expected Bellatrix state for execution block production"); } @@ -430,10 +409,10 @@ export async function produceBlockBody( this.logger, fork, parentBlockRoot, - currentState.latestExecutionPayloadHeader.blockHash, + parentState.latestExecutionPayloadHeader.blockHash, safeBlockHash, finalizedBlockHash ?? ZERO_HASH_HEX, - currentState, + parentState, executionBuilder.issueLocalFcUWithFeeRecipient ); } @@ -445,7 +424,7 @@ export async function produceBlockBody( slot: blockSlot, proposerPubKey: toHex(proposerPubKey), }); - const headerRes = await prepareExecutionPayloadHeader(this, fork, currentState, proposerPubKey); + const headerRes = await prepareExecutionPayloadHeader(this, fork, parentState, proposerPubKey); endExecutionPayloadHeader?.({ step: BlockProductionStep.executionPayload, @@ -480,7 +459,7 @@ export async function produceBlockBody( }); } else { const headerGasLimit = builderRes.header.gasLimit; - const parentGasLimit = currentState.latestExecutionPayloadHeader.gasLimit; + const parentGasLimit = parentState.latestExecutionPayloadHeader.gasLimit; const expectedGasLimit = getExpectedGasLimit(parentGasLimit, targetGasLimit); const lowerBound = Math.min(parentGasLimit, expectedGasLimit); @@ -539,10 +518,10 @@ export async function produceBlockBody( this.logger, fork, parentBlockRoot, - currentState.latestExecutionPayloadHeader.blockHash, + parentState.latestExecutionPayloadHeader.blockHash, safeBlockHash, finalizedBlockHash ?? ZERO_HASH_HEX, - currentState, + parentState, feeRecipient ); @@ -985,8 +964,7 @@ function getProposerTargetGasLimit( export async function produceCommonBlockBody( this: BeaconChain, blockType: T, - currentState: IBeaconStateView, - {randaoReveal, graffiti, slot, parentBlock}: BlockAttributes + {randaoReveal, graffiti, slot, parentBlock, parentStateWithPayload}: BlockAttributes ): Promise { const stepsMetrics = blockType === BlockType.Full @@ -1007,14 +985,14 @@ export async function produceCommonBlockBody( // } // } const [attesterSlashings, proposerSlashings, voluntaryExits, blsToExecutionChanges] = - this.opPool.getSlashingsAndExits(currentState, blockType, this.metrics); + this.opPool.getSlashingsAndExits(parentStateWithPayload, blockType, this.metrics); const endAttestations = stepsMetrics?.startTimer(); const attestations = this.aggregatedAttestationPool.getAttestationsForBlock( fork, this.forkChoice, this.shufflingCache, - currentState + parentStateWithPayload ); endAttestations?.({ step: BlockProductionStep.attestations, @@ -1024,7 +1002,7 @@ export async function produceCommonBlockBody( randaoReveal, graffiti, // Eth1 data voting is no longer required since electra - eth1Data: currentState.eth1Data, + eth1Data: parentStateWithPayload.eth1Data, proposerSlashings, attesterSlashings, attestations, diff --git a/packages/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index 0c1f9b77bbfe..c570530ab8a4 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -178,6 +178,7 @@ vi.mock("../../src/chain/chain.js", async (importActual) => { pubkeyCache: createPubkeyCache(), produceCommonBlockBody: vi.fn(), getProposerHead: vi.fn(), + getParentExecutionRequests: vi.fn(), produceBlock: vi.fn(), produceBlindedBlock: vi.fn(), getCanonicalBlockAtSlot: vi.fn(), diff --git a/packages/beacon-node/test/perf/chain/produceBlock/produceBlockBody.test.ts b/packages/beacon-node/test/perf/chain/produceBlock/produceBlockBody.test.ts index 9f503b56648d..4f8dbe6456c5 100644 --- a/packages/beacon-node/test/perf/chain/produceBlock/produceBlockBody.test.ts +++ b/packages/beacon-node/test/perf/chain/produceBlock/produceBlockBody.test.ts @@ -93,14 +93,16 @@ describe("produceBlockBody", () => { fn: async ({chain, state, head, proposerIndex, proposerPubKey}) => { const slot = state.slot; - const commonBlockBodyPromise = produceCommonBlockBody.call(chain, BlockType.Full, state, { + const commonBlockBodyPromise = produceCommonBlockBody.call(chain, BlockType.Full, { slot: slot + 1, graffiti: Buffer.alloc(32), randaoReveal: Buffer.alloc(96), parentBlock: head, + parentState: state, + parentStateWithPayload: state, }); - await produceBlockBody.call(chain, BlockType.Full, state, { + await produceBlockBody.call(chain, BlockType.Full, { slot: slot + 1, graffiti: Buffer.alloc(32), randaoReveal: Buffer.alloc(96), @@ -108,6 +110,8 @@ describe("produceBlockBody", () => { proposerIndex, proposerPubKey, commonBlockBodyPromise, + parentState: state, + parentStateWithPayload: state, }); }, }); diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts index 27416f7794dd..31ace8947dce 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts @@ -315,7 +315,7 @@ describe("api/validator - produceBlockV3", () => { }); // use fee recipient passed in produceBlockBody call for payload gen in engine notifyForkchoiceUpdate - await produceBlockBody.call(modules.chain as unknown as BeaconChain, BlockType.Full, state, { + await produceBlockBody.call(modules.chain as unknown as BeaconChain, BlockType.Full, { randaoReveal, graffiti: toGraffitiBytes(graffiti), slot, @@ -324,6 +324,8 @@ describe("api/validator - produceBlockV3", () => { proposerIndex: 0, proposerPubKey: new Uint8Array(32).fill(1), commonBlockBodyPromise: createCommonBlockBodyPromise(), + parentState: state, + parentStateWithPayload: state, }); expect(modules.chain["executionEngine"].notifyForkchoiceUpdate).toBeCalledWith( @@ -341,7 +343,7 @@ describe("api/validator - produceBlockV3", () => { // use fee recipient set in beaconProposerCacheStub if none passed modules.chain["beaconProposerCache"].getOrDefault.mockReturnValue("0x fee recipient address"); - await produceBlockBody.call(modules.chain as unknown as BeaconChain, BlockType.Full, state, { + await produceBlockBody.call(modules.chain as unknown as BeaconChain, BlockType.Full, { randaoReveal, graffiti: toGraffitiBytes(graffiti), slot, @@ -349,6 +351,8 @@ describe("api/validator - produceBlockV3", () => { proposerIndex: 0, proposerPubKey: new Uint8Array(32).fill(1), commonBlockBodyPromise: createCommonBlockBodyPromise(), + parentState: state, + parentStateWithPayload: state, }); expect(modules.chain["executionEngine"].notifyForkchoiceUpdate).toBeCalledWith( diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts index 2b21dd14020e..fc24be820278 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV4.test.ts @@ -3,6 +3,7 @@ import {routes} from "@lodestar/api"; import {createBeaconConfig, createChainForkConfig, defaultChainConfig} from "@lodestar/config"; import {ProtoBlock} from "@lodestar/fork-choice"; import {ForkName} from "@lodestar/params"; +import {IBeaconStateView} from "@lodestar/state-transition"; import {ssz} from "@lodestar/types"; import {getValidatorApi} from "../../../../../src/api/impl/validator/index.js"; import {defaultApiOptions} from "../../../../../src/api/options.js"; @@ -61,6 +62,14 @@ describe("api/validator - produceBlockV4", () => { modules.chain.getProposerHead.mockReturnValue(parentBlock); modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(zeroProtoBlock); modules.chain.forkChoice.shouldBuildOnFull.mockReturnValue(true); + // produceBlockV4 fetches the production state once and applies the parent payload before delegating + // to the (mocked) chain.produceCommonBlockBody / chain.produceBlock + const parentState = {forkName: ForkName.gloas} as unknown as IBeaconStateView & { + withParentPayloadApplied: () => unknown; + }; + parentState.withParentPayloadApplied = vi.fn().mockReturnValue(parentState); + modules.chain.regen.getBlockSlotState.mockResolvedValue(parentState); + modules.chain.getParentExecutionRequests.mockResolvedValue(ssz.gloas.ExecutionRequests.defaultValue()); modules.chain.produceBlock.mockImplementation(async (attrs: {builderBid?: unknown}) => ({ block: attrs.builderBid !== undefined ? bidBlock : engineBlock, executionPayloadValue: BigInt(0), From 45a8bc2dc4049f6dbd5d4ca5ad19415ced7c007e Mon Sep 17 00:00:00 2001 From: twoeths Date: Fri, 31 Jul 2026 14:32:21 +0700 Subject: [PATCH 4/4] fix: compute block reward using parent state with payload applied --- .../state-transition/src/rewards/blockRewards.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/state-transition/src/rewards/blockRewards.ts b/packages/state-transition/src/rewards/blockRewards.ts index e19773d9ca1a..df5829d4c527 100644 --- a/packages/state-transition/src/rewards/blockRewards.ts +++ b/packages/state-transition/src/rewards/blockRewards.ts @@ -6,8 +6,9 @@ import { isForkPostElectra, isForkPostGloas, } from "@lodestar/params"; -import {BeaconBlock, Slot, altair, phase0, rewards} from "@lodestar/types"; +import {BeaconBlock, Slot, altair, gloas, phase0, rewards} from "@lodestar/types"; import {processAttestationsAltair} from "../block/processAttestationsAltair.js"; +import {processParentExecutionPayload} from "../block/processParentExecutionPayload.js"; import {RewardCache} from "../cache/rewardCache.js"; import { CachedBeaconStateAllForks, @@ -42,9 +43,12 @@ export async function computeBlockRewards( let syncAggregateReward = cachedSyncAggregateReward; if (blockAttestationReward === 0) { - const parentSlot = isForkPostGloas(fork) - ? (preState as CachedBeaconStateGloas).latestExecutionPayloadBid.slot - : null; + let parentSlot: Slot | null = null; + if (isForkPostGloas(fork)) { + const gloasState = preState as CachedBeaconStateGloas; + parentSlot = gloasState.latestExecutionPayloadBid.slot; + processParentExecutionPayload(gloasState, block as gloas.BeaconBlock); + } blockAttestationReward = fork === ForkName.phase0 ? computeBlockAttestationRewardPhase0(block as phase0.BeaconBlock, preState as CachedBeaconStatePhase0)