Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions packages/beacon-node/src/api/impl/beacon/state/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {PubkeyIndexMap} from "@chainsafe/pubkey-index-map";
import {routes} from "@lodestar/api";
import {CheckpointWithPayload, IForkChoice} from "@lodestar/fork-choice";
import {GENESIS_SLOT} from "@lodestar/params";
import {CheckpointWithPayload, IForkChoice, PayloadStatus} from "@lodestar/fork-choice";
import {ForkSeq, GENESIS_SLOT} from "@lodestar/params";
import {BeaconStateAllForks, CachedBeaconStateAllForks} from "@lodestar/state-transition";
import {BLSPubkey, Epoch, RootHex, Slot, ValidatorIndex, getValidatorStatus, phase0} from "@lodestar/types";
import {fromHex} from "@lodestar/utils";
Expand Down Expand Up @@ -47,7 +47,18 @@ export async function getStateResponseWithRegen(
): Promise<{state: CachedBeaconStateAllForks | Uint8Array; executionOptimistic: boolean; finalized: boolean}> {
const stateId = resolveStateId(chain.forkChoice, inStateId);

const res =
// For checkpoint identifiers on post-Gloas forks, serve the consensus post-state (EMPTY path)
// rather than a post-envelope variant. This matches spec intent and Prysm checkpoint behavior.
const checkpointStateId =
typeof stateId !== "string" && typeof stateId !== "number"
? inStateId === "finalized" || inStateId === "justified"
? chain.config.getForkSeqAtEpoch(stateId.epoch) >= ForkSeq.gloas
? {...stateId, payloadStatus: PayloadStatus.EMPTY}
: stateId
: stateId
: null;

let res =
typeof stateId === "string"
? await chain.getStateByStateRoot(stateId, {allowRegen: true})
: typeof stateId === "number"
Expand All @@ -56,7 +67,13 @@ export async function getStateResponseWithRegen(
: stateId >= chain.forkChoice.getFinalizedBlock().slot
? await chain.getStateBySlot(stateId, {allowRegen: true})
: await chain.getHistoricalStateBySlot(stateId)
: await chain.getStateOrBytesByCheckpoint(stateId);
: await chain.getStateOrBytesByCheckpoint(checkpointStateId ?? stateId);

// Defensive fallback: if post-Gloas checkpoint normalization prefers EMPTY but that
// variant is unavailable, retry original checkpoint status before returning 404.
if (!res && checkpointStateId) {
res = await chain.getStateOrBytesByCheckpoint(stateId);
}

if (!res) {
throw new ApiError(404, `State not found for id '${inStateId}'`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -405,23 +405,32 @@ async function migrateExecutionPayloadEnvelopesFromHotToColdDb(

if (canonicalBlocks.length === 0) break;

const canonicalEnvelopeEntries: KeyValue<Slot, Uint8Array>[] = await Promise.all(
const canonicalEnvelopeEntries = await Promise.all(
canonicalBlocks.map(async (block) => {
const envelopeBytes = await db.executionPayloadEnvelope.getBinary(block.root);
if (!envelopeBytes) {
throw Error(`No executionPayloadEnvelope found for slot ${block.slot} root ${toRootHex(block.root)}`);
// Not every canonical Gloas block is guaranteed to have a revealed envelope.
// Skip missing envelopes (orphaned/unrevealed payload path) instead of failing
// finalized archival processing.
return null;
}

return {key: block.slot, value: envelopeBytes};
return {slot: block.slot, root: block.root, value: envelopeBytes};
})
);

await Promise.all([
db.executionPayloadEnvelopeArchive.batchPutBinary(canonicalEnvelopeEntries),
db.executionPayloadEnvelope.batchDelete(canonicalBlocks.map((block) => block.root)),
]);
const envelopesToArchive = canonicalEnvelopeEntries.filter((entry) => entry !== null);

if (envelopesToArchive.length > 0) {
await Promise.all([
db.executionPayloadEnvelopeArchive.batchPutBinary(
envelopesToArchive.map((entry) => ({key: entry.slot, value: entry.value}))
),
db.executionPayloadEnvelope.batchDelete(envelopesToArchive.map((entry) => entry.root)),
]);
}

migratedEnvelopes += canonicalEnvelopeEntries.length;
migratedEnvelopes += envelopesToArchive.length;
}

return migratedEnvelopes;
Expand Down
17 changes: 17 additions & 0 deletions packages/beacon-node/src/chain/blocks/verifyBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,23 @@ export async function verifyBlocksInEpoch(
const updatedParent = this.forkChoice.getBlockHex(parentRootHex, PayloadStatus.FULL);
if (updatedParent) break;

const downloadedParentEnvelope = envelopes?.get(parentBlock.slot);
if (downloadedParentEnvelope && toRootHex(downloadedParentEnvelope.message.beaconBlockRoot) === parentRootHex) {
try {
await this.importExecutionPayloadEnvelope(downloadedParentEnvelope);
envelopes?.delete(parentBlock.slot);
this.logger.info("Imported downloaded parent envelope before block import", {
parentRoot: parentRootHex,
parentSlot: parentBlock.slot,
childSlot: block0.message.slot,
attempt,
});
break;
} catch (e) {
this.logger.debug("Failed importing downloaded parent envelope", {parentRoot: parentRootHex}, e as Error);
}
}

const pendingEnvelope = this.pendingEnvelopes.get(parentRootHex);
if (pendingEnvelope) {
try {
Expand Down
11 changes: 7 additions & 4 deletions packages/beacon-node/src/chain/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
computeSyncCommitteeRewards,
getEffectiveBalanceIncrementsZeroInactive,
getEffectiveBalancesFromStateBytes,
isParentBlockFull,
processSlots,
} from "@lodestar/state-transition";
import {processExecutionPayloadEnvelope} from "@lodestar/state-transition/block";
Expand Down Expand Up @@ -384,11 +385,13 @@ export class BeaconChain implements IBeaconChain {
const {checkpoint} = computeAnchorCheckpoint(config, anchorState);
blockStateCache.add(anchorState);
blockStateCache.setHeadState(anchorState);
// TODO: For Gloas, determine if anchor state is block state or payload state
// Determine payload status from anchor state for Gloas
// Pre-Gloas: payloadPresent is always true (execution payload embedded in block)
// Post-Gloas: Could be either - depends on whether anchor was loaded with payload processing
// For now, assume true
checkpointStateCache.add(checkpoint, anchorState, true);
// Post-Gloas: check if envelope was applied using isParentBlockFull()
const anchorPayloadPresent = isForkPostGloas(config.getForkName(anchorState.slot))
? isParentBlockFull(anchorState as CachedBeaconStateGloas)
: true;
checkpointStateCache.add(checkpoint, anchorState, anchorPayloadPresent);

const forkChoice = initializeForkChoice(
config,
Expand Down
47 changes: 34 additions & 13 deletions packages/beacon-node/src/chain/forkChoice/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
ForkChoiceOpts as RawForkChoiceOpts,
getCheckpointPayloadStatus,
} from "@lodestar/fork-choice";
import {ZERO_HASH_HEX} from "@lodestar/params";
import {ForkSeq, ZERO_HASH_HEX} from "@lodestar/params";
import {
CachedBeaconStateAllForks,
CachedBeaconStateGloas,
Expand All @@ -22,6 +22,7 @@ import {
getEffectiveBalanceIncrementsZeroInactive,
isExecutionStateType,
isMergeTransitionComplete,
isParentBlockFull,
} from "@lodestar/state-transition";
import {Slot, ssz} from "@lodestar/types";
import {Logger, toRootHex} from "@lodestar/utils";
Expand Down Expand Up @@ -106,7 +107,7 @@ export function initializeForkChoiceFromFinalizedState(
// production code use ForkChoice constructor directly
const forkchoiceConstructor = opts.forkchoiceConstructor ?? ForkChoice;

const isForkPostGloas = (state as CachedBeaconStateGloas).latestBlockHash !== undefined;
const isForkPostGloas = config.getForkSeq(state.slot) >= ForkSeq.gloas;

// Determine justified checkpoint payload status
const justifiedPayloadStatus = getCheckpointPayloadStatus(state, justifiedCheckpoint.epoch);
Expand Down Expand Up @@ -148,16 +149,26 @@ export function initializeForkChoiceFromFinalizedState(
unrealizedFinalizedEpoch: finalizedCheckpoint.epoch,
unrealizedFinalizedRoot: toRootHex(finalizedCheckpoint.root),

...(isExecutionStateType(state) && isMergeTransitionComplete(state)
...(isForkPostGloas
? {
executionPayloadBlockHash: toRootHex(state.latestExecutionPayloadHeader.blockHash),
executionPayloadNumber: state.latestExecutionPayloadHeader.blockNumber,
executionPayloadBlockHash: toRootHex((state as CachedBeaconStateGloas).latestBlockHash),
executionPayloadNumber: 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve anchor execution payload number for Gloas

executionPayloadNumber is hardcoded to 0 for post-Gloas anchor initialization here (and repeated in the unfinalized initializer), which drops the execution height available from the loaded state. ForkChoice.onBlock() computes child executionPayloadNumber from parentBlock.executionPayloadNumber, and getPayloadAttributesForSSE() forwards that value to payload-attributes events, so a restarted node can emit near-zero parent block numbers and propagate incorrect numbering until envelopes are replayed. This is a correctness regression for APIs/consumers that depend on parent_block_number continuity.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — I took a close look here. For post-Gloas checkpoint init we currently do not have an execution block number in state (EIP-7732 removed latestExecutionPayloadHeader, and latestExecutionPayloadBid has hashes/value but no block number), so we cannot recover a reliable parent block number at this initialization point.\n\nI kept 0 as a sentinel for now to avoid fabricating potentially wrong heights, and to keep this PR focused on the restart + finalized-state correctness fixes. I agree this is worth improving separately (e.g., deriving from persisted envelope/index when available). I can open a follow-up task for that if you want.

executionStatus: blockHeader.slot === GENESIS_SLOT ? ExecutionStatus.Valid : ExecutionStatus.Syncing,
}
: {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}),
: isExecutionStateType(state) && isMergeTransitionComplete(state)
? {
executionPayloadBlockHash: toRootHex(state.latestExecutionPayloadHeader.blockHash),
executionPayloadNumber: state.latestExecutionPayloadHeader.blockNumber,
executionStatus: blockHeader.slot === GENESIS_SLOT ? ExecutionStatus.Valid : ExecutionStatus.Syncing,
}
: {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
? isParentBlockFull(state as CachedBeaconStateGloas)
? PayloadStatus.FULL
: PayloadStatus.EMPTY
: PayloadStatus.FULL,
builderIndex: isForkPostGloas ? (state as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex : null,
blockHashFromBid: isForkPostGloas
? toRootHex((state as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash)
Expand Down Expand Up @@ -206,7 +217,7 @@ export function initializeForkChoiceFromUnfinalizedState(
// this is not the justified state, but there is no other ways to get justified balances
const justifiedBalances = getEffectiveBalanceIncrementsZeroInactive(unfinalizedState);

const isForkPostGloas = (unfinalizedState as CachedBeaconStateGloas).latestBlockHash !== undefined;
const isForkPostGloas = config.getForkSeq(unfinalizedState.slot) >= ForkSeq.gloas;

// For unfinalized state, use getCheckpointPayloadStatus to determine the correct status.
// It checks state.execution_payload_availability to determine EMPTY vs FULL.
Expand Down Expand Up @@ -245,16 +256,26 @@ export function initializeForkChoiceFromUnfinalizedState(
unrealizedFinalizedEpoch: finalizedCheckpoint.epoch,
unrealizedFinalizedRoot: toRootHex(finalizedCheckpoint.root),

...(isExecutionStateType(unfinalizedState) && isMergeTransitionComplete(unfinalizedState)
...(isForkPostGloas
? {
executionPayloadBlockHash: toRootHex(unfinalizedState.latestExecutionPayloadHeader.blockHash),
executionPayloadNumber: unfinalizedState.latestExecutionPayloadHeader.blockNumber,
executionPayloadBlockHash: toRootHex((unfinalizedState as CachedBeaconStateGloas).latestBlockHash),
executionPayloadNumber: 0,
executionStatus: blockHeader.slot === GENESIS_SLOT ? ExecutionStatus.Valid : ExecutionStatus.Syncing,
}
: {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}),
: isExecutionStateType(unfinalizedState) && isMergeTransitionComplete(unfinalizedState)
? {
executionPayloadBlockHash: toRootHex(unfinalizedState.latestExecutionPayloadHeader.blockHash),
executionPayloadNumber: unfinalizedState.latestExecutionPayloadHeader.blockNumber,
executionStatus: blockHeader.slot === GENESIS_SLOT ? ExecutionStatus.Valid : ExecutionStatus.Syncing,
}
: {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
? isParentBlockFull(unfinalizedState as CachedBeaconStateGloas)
? PayloadStatus.FULL
: PayloadStatus.EMPTY
: PayloadStatus.FULL,
builderIndex: isForkPostGloas
? (unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex
: null,
Expand Down
11 changes: 8 additions & 3 deletions packages/beacon-node/src/chain/regen/queued.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,15 @@ export class QueuedStateRegenerator implements IStateRegenerator {
getClosestHeadState(head: ProtoBlock): CachedBeaconStateAllForks | null {
// Convert PayloadStatus to payloadPresent boolean.
// PENDING blocks are in fork-choice but lack an envelope — treat as non-FULL.
const payloadPresent = head.payloadStatus === PayloadStatus.FULL;
const preferredPayloadPresent = head.payloadStatus === PayloadStatus.FULL;

// In some restart edge cases, fork-choice may reference a head variant whose payload status
// differs from the variant persisted in checkpoint cache. Fall back to the opposite variant
// to avoid startup failures (`headState does not exist`).
return (
this.checkpointStateCache.getLatest(head.blockRoot, Infinity, payloadPresent) ||
this.blockStateCache.get(head.stateRoot)
this.checkpointStateCache.getLatest(head.blockRoot, Infinity, preferredPayloadPresent) ||
this.blockStateCache.get(head.stateRoot) ||
this.checkpointStateCache.getLatest(head.blockRoot, Infinity, !preferredPayloadPresent)
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {describe, expect, it} from "vitest";
import {describe, expect, it, vi} from "vitest";
import {toHexString} from "@chainsafe/ssz";
import {getStateValidatorIndex} from "../../../../../../src/api/impl/beacon/state/utils.js";
import {PayloadStatus} from "@lodestar/fork-choice";
import {ForkSeq} from "@lodestar/params";
import {getStateResponseWithRegen, getStateValidatorIndex} from "../../../../../../src/api/impl/beacon/state/utils.js";
import {generateCachedAltairState} from "../../../../../utils/state.js";

describe("beacon state api utils", () => {
Expand Down Expand Up @@ -58,3 +60,45 @@ describe("beacon state api utils", () => {
});
});
});

describe("getStateResponseWithRegen", () => {
it("falls back to original checkpoint payload status if forced EMPTY lookup misses", async () => {
const finalizedCheckpoint = {
epoch: 123,
rootHex: "0xabc",
payloadStatus: PayloadStatus.FULL,
payloadPresent: true,
};

const expectedResponse = {
state: new Uint8Array([1, 2, 3]),
executionOptimistic: false,
finalized: true,
};

const chain = {
forkChoice: {
getFinalizedCheckpoint: vi.fn().mockReturnValue(finalizedCheckpoint),
},
config: {
getForkSeqAtEpoch: vi.fn().mockReturnValue(ForkSeq.gloas),
},
clock: {
currentSlot: 1000,
},
getStateByStateRoot: vi.fn(),
getStateBySlot: vi.fn(),
getHistoricalStateBySlot: vi.fn(),
getStateOrBytesByCheckpoint: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(expectedResponse),
} as never;

const response = await getStateResponseWithRegen(chain, "finalized");

expect(response).toBe(expectedResponse);
expect(chain.getStateOrBytesByCheckpoint).toHaveBeenNthCalledWith(1, {
...finalizedCheckpoint,
payloadStatus: PayloadStatus.EMPTY,
});
expect(chain.getStateOrBytesByCheckpoint).toHaveBeenNthCalledWith(2, finalizedCheckpoint);
});
});
Loading
Loading