Skip to content

fix(epbs): checkpoint restart stability + finalized state API parity - #9005

Closed
lodekeeper wants to merge 4 commits into
ChainSafe:epbs-devnet-0from
lodekeeper:fix/epbs-state-restart
Closed

fix(epbs): checkpoint restart stability + finalized state API parity#9005
lodekeeper wants to merge 4 commits into
ChainSafe:epbs-devnet-0from
lodekeeper:fix/epbs-state-restart

Conversation

@lodekeeper

Copy link
Copy Markdown
Contributor

Summary

This patch fixes EPBS checkpoint/restart behavior and finalized state serving semantics to match spec intent (consensus post-state) and Prysm checkpoint behavior.

Fixes included

  1. Restart crash (headState does not exist) hardening

    • Derive anchor checkpoint cache payload variant from state (isParentBlockFull) instead of hardcoded true
    • Derive Gloas fork-choice anchor payloadStatus as FULL/EMPTY from state instead of defaulting to PENDING
    • Add getClosestHeadState() fallback to opposite payload variant when preferred variant is missing
  2. Range sync prestate failure on first post-checkpoint block

    • Import downloaded parent envelope before validating child blocks that require FULL parent path
    • This fixes recurring BLOCK_ERROR_PRESTATE_MISSING / REGEN_ERROR_BLOCK_NOT_IN_FORKCHOICE loops after checkpoint sync
  3. Finalized v2 state serving semantics

    • For finalized/justified state IDs on post-Gloas forks, normalize to EMPTY checkpoint variant (consensus post-state)
    • Aligns with potuz guidance + Prysm checkpoint endpoint behavior
  4. Finalized archival robustness

    • Envelope migration hot→cold now skips missing/unrevealed envelopes instead of hard failing finalized checkpoint processing

Why

On EPBS devnet-0, restarting from a synced DB state could crash at startup and checkpoint sync could stall due parent-path mismatches. Also /eth/v2/debug/beacon/states/finalized could return a post-envelope variant (or 404) instead of the consensus post-state expected from checkpoint sync providers.

Validation

Live-devnet validation on epbs-devnet-0 with:

  • --checkpointSyncUrl https://checkpoint-sync.epbs-devnet-0.ethpandaops.io/
  • --execution.engineMock
  • bootnodes from config.epbs-devnet-0.ethpandaops.io

Observed

  • ✅ Checkpoint sync reaches Synced
  • ✅ Restart from DB state succeeds (no headState does not exist crash)
  • ✅ Finalized state SSZ from local /eth/v2/debug/beacon/states/finalized matches checkpoint endpoint exactly (same SHA256)
  • ✅ Finalized state is post-CL (latest_block_hash == latest_execution_payload_bid.parent_block_hash)

Notes

  • This PR intentionally follows the consensus post-state serving model for checkpoint IDs.
  • Existing sync fix behavior for orphaned/unrevealed envelopes remains intact.

@lodekeeper
lodekeeper requested a review from a team as a code owner March 7, 2026 00:38
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the stability and correctness of the EPBS beacon node, particularly concerning checkpoint synchronization and state management. It addresses critical issues that caused crashes during restarts and stalls during range synchronization, ensuring a more resilient node operation. Furthermore, it refines the serving semantics for finalized states to accurately reflect the consensus post-state, bringing the API in line with specifications and other client implementations.

Highlights

  • Checkpoint Restart Hardening: Improved stability during restarts by dynamically determining the payload status of anchor checkpoints and adding a fallback mechanism for missing head states.
  • Range Sync Prestate Fix: Resolved issues with range synchronization by ensuring downloaded parent execution payload envelopes are imported before child blocks are validated.
  • Finalized State API Parity: Aligned the /eth/v2/debug/beacon/states/finalized endpoint's behavior for post-Gloas forks to return the consensus post-state (EMPTY path), matching spec intent and Prysm's implementation.
  • Archival Robustness: Enhanced the robustness of finalized archival processing by allowing the migration of execution payload envelopes to skip missing or unrevealed envelopes instead of failing.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • packages/beacon-node/src/api/impl/beacon/state/utils.ts
    • Added logic to normalize finalized and justified state IDs to the EMPTY payload status for post-Gloas forks, ensuring correct state serving semantics.
    • Imported PayloadStatus and ForkSeq for new state resolution logic.
  • packages/beacon-node/src/chain/archiveStore/utils/archiveBlocks.ts
    • Modified the execution payload envelope migration process to gracefully skip missing or unrevealed envelopes, preventing hard failures during finalized archival.
  • packages/beacon-node/src/chain/blocks/verifyBlock.ts
    • Implemented a mechanism to import downloaded parent execution payload envelopes before validating child blocks, resolving range sync prestate failures.
  • packages/beacon-node/src/chain/chain.ts
    • Updated the anchor checkpoint initialization to dynamically determine anchorPayloadPresent using isParentBlockFull for post-Gloas forks, improving restart stability.
  • packages/beacon-node/src/chain/forkChoice/index.ts
    • Refined the initialization of fork choice from both finalized and unfinalized states to correctly identify post-Gloas forks and set the payloadStatus based on isParentBlockFull.
    • Adjusted the handling of executionPayloadBlockHash and executionPayloadNumber for Gloas states.
  • packages/beacon-node/src/chain/regen/queued.ts
    • Introduced a fallback mechanism in getClosestHeadState to search for the opposite payload variant if the preferred one is not found, preventing "headState does not exist" crashes during restarts.
Activity
  • No specific activity (comments, reviews, progress) has been recorded for this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces several important fixes to enhance the stability and correctness of EPBS checkpoint/restart behavior and state serving, aligning it with the specification. The changes include hardening against restart crashes by correctly deriving payload status from the state, fixing range sync failures by importing parent envelopes earlier, ensuring correct finalized state serving for post-Gloas forks, and improving the robustness of archival by skipping missing envelopes. The code changes are logical and directly address the issues described. I have a few minor suggestions to improve type safety by avoiding an overly aggressive type cast.

? {
executionPayloadBlockHash: toRootHex(state.latestExecutionPayloadHeader.blockHash),
executionPayloadNumber: state.latestExecutionPayloadHeader.blockNumber,
executionPayloadBlockHash: toRootHex((state as unknown as CachedBeaconStateGloas).latestBlockHash),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The cast as unknown as CachedBeaconStateGloas is a bit aggressive and bypasses all type-checking. A simpler cast as CachedBeaconStateGloas should be sufficient here, as the isForkPostGloas check ensures the state is of the correct type. Using as unknown should be reserved for cases where it's truly necessary, and ideally accompanied by a comment explaining why. A similar issue exists on line 177.

Suggested change
executionPayloadBlockHash: toRootHex((state as unknown as CachedBeaconStateGloas).latestBlockHash),
executionPayloadBlockHash: toRootHex((state as CachedBeaconStateGloas).latestBlockHash),

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 @gemini-code-assist — simplified these to direct as CachedBeaconStateGloas casts (removed as unknown as) in follow-up commit 1572659.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

null

Suggested change
executionPayloadBlockHash: toRootHex((state as unknown as CachedBeaconStateGloas).latestBlockHash),
null

: null,
parentBlockHash: isForkPostGloas ? toRootHex((state as CachedBeaconStateGloas).latestBlockHash) : null,
parentBlockHash: isForkPostGloas
? toRootHex((state as unknown as CachedBeaconStateGloas).latestBlockHash)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Similar to the comment on line 154, the as unknown as cast is likely unnecessary here. A direct cast to CachedBeaconStateGloas should work and is safer.

Suggested change
? toRootHex((state as unknown as CachedBeaconStateGloas).latestBlockHash)
? toRootHex((state as CachedBeaconStateGloas).latestBlockHash)

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 @gemini-code-assist — simplified these to direct as CachedBeaconStateGloas casts (removed as unknown as) in follow-up commit 1572659.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That's great to hear! I'm glad the suggestion was helpful in simplifying the code.

? {
executionPayloadBlockHash: toRootHex(unfinalizedState.latestExecutionPayloadHeader.blockHash),
executionPayloadNumber: unfinalizedState.latestExecutionPayloadHeader.blockNumber,
executionPayloadBlockHash: toRootHex((unfinalizedState as unknown as CachedBeaconStateGloas).latestBlockHash),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The cast as unknown as CachedBeaconStateGloas is a bit aggressive and bypasses all type-checking. A simpler cast as CachedBeaconStateGloas should be sufficient here, as the isForkPostGloas check ensures the state is of the correct type. Using as unknown should be reserved for cases where it's truly necessary, and ideally accompanied by a comment explaining why. A similar issue exists on line 288.

Suggested change
executionPayloadBlockHash: toRootHex((unfinalizedState as unknown as CachedBeaconStateGloas).latestBlockHash),
executionPayloadBlockHash: toRootHex((unfinalizedState as CachedBeaconStateGloas).latestBlockHash),

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 @gemini-code-assist — simplified these to direct as CachedBeaconStateGloas casts (removed as unknown as) in follow-up commit 1572659.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The issue has been resolved by the author. No further action is needed from my side.

: null,
parentBlockHash: isForkPostGloas ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestBlockHash) : null,
parentBlockHash: isForkPostGloas
? toRootHex((unfinalizedState as unknown as CachedBeaconStateGloas).latestBlockHash)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Similar to the comment on line 263, the as unknown as cast is likely unnecessary here. A direct cast to CachedBeaconStateGloas should work and is safer.

Suggested change
? toRootHex((unfinalizedState as unknown as CachedBeaconStateGloas).latestBlockHash)
? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestBlockHash)

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 @gemini-code-assist — simplified these to direct as CachedBeaconStateGloas casts (removed as unknown as) in follow-up commit 1572659.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for addressing this. It's good to see the simplification of the casts.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3210d92b4f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

executionPayloadBlockHash: toRootHex(state.latestExecutionPayloadHeader.blockHash),
executionPayloadNumber: state.latestExecutionPayloadHeader.blockNumber,
executionPayloadBlockHash: toRootHex((state as unknown 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.

@nflaig

nflaig commented Mar 7, 2026

Copy link
Copy Markdown
Member

squash merged via 0daf882, I fixed the build in my commit, going to close this as the change was merged manually

@nflaig nflaig closed this Mar 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants