Skip to content

Compute correctly the finalized field value in a bunch of beacon API endpoints. - #17401

Merged
nalepae merged 5 commits into
developfrom
optimistic-statediff
Aug 25, 2026
Merged

Compute correctly the finalized field value in a bunch of beacon API endpoints.#17401
nalepae merged 5 commits into
developfrom
optimistic-statediff

Conversation

@nalepae

@nalepae nalepae commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

What type of PR is this?
Bug fix

What does this PR do? Why is it needed?
On a bunch of beacon API endpoints, the finalized field is always set to false, even if it should actually return true.

These endpoints are:

  • /eth/v1/beacon/states/{state_id}/root,
  • /fork,
  • /randao,
  • /committees,
  • /sync_committees,
  • /finality_checkpoints,
  • /validators,
  • /validator_balances,
  • /validator_identities,
  • /pending_consolidations,
  • /pending_deposits,
  • /pending_partial_withdrawals,
  • /proposer_lookahead,
  • /eth/v2/debug/beacon/states/{state_id}, and
  • /prysm/v1/beacon/states/{state_id}/validator_count.

First, explaining the egg and chicken issue.
When producing a new block, the block itself must contain (post) state root.

class BeaconBlockHeader(Container):
    slot: Slot
    proposer_index: ValidatorIndex
    parent_root: Root
    state_root: Root
    body_root: Root

This state root represents a state, which itself contains the latest beacon block header:

class BeaconState(Container):
    # Versioning
    genesis_time: uint64
    genesis_validators_root: Root
    slot: Slot
    fork: Fork
    # History
    latest_block_header: BeaconBlockHeader
    ...

The beacon spec explicitly asks to set the state.latest_block_header.state_root to 0.

def process_block_header(state: BeaconState, block: BeaconBlock) -> None:
    # Verify that the slots match
    assert block.slot == state.slot
    # Verify that the block is newer than latest block header
    assert block.slot > state.latest_block_header.slot
    # Verify that proposer index is the correct index
    assert block.proposer_index == get_beacon_proposer_index(state)
    # Verify that the parent matches
    assert block.parent_root == hash_tree_root(state.latest_block_header)
    # Cache current block as the new latest block
    state.latest_block_header = BeaconBlockHeader(
        slot=block.slot,
        proposer_index=block.proposer_index,
        parent_root=block.parent_root,
        state_root=Bytes32(),  # Overwritten in the next process_slot call
        body_root=hash_tree_root(block.body),
    )

    # Verify proposer is not slashed
    proposer = state.validators[block.proposer_index]
    assert not proposer.slashed

Why?
Simply because the proposer needs block.state_root = htr(post_state). But post_state contains latest_block_header, which is this block's header, which contains state_root.
So if the state carried the real value, the proposer would need x such that:

x = htr(state_containing_x)

which is unsolvable.

With the zero placeholder it collapses to a single evaluation. x = htr(state_containing_0) and the verifier redoes that one evaluation to check it.

Why this is related to the "finalized:" false issue?
Because, when the beacon node needs to compute the block root from a state, it uses:

blockRoot, err := st.LatestBlockHeader().HashTreeRoot()

This latest block header actually contains the zeroed state_root field.

Then, it looks if the computed blockRoot is finalized. Because the blockRoot is not found, it always return false.

The fix introduces a new BlockRootFromState function, that actually replaces the zeroed state_root field by the correct one.

How to test it?

Without the fix, call the /eth/v1/beacon/states/<slot>/root endpoint with any finalized slot.
It will always return "finalized": false.

Example:

curl http://localhost:3500/eth/v1/beacon/states/15061500/root | jq
{
  "execution_optimistic": false,
  "finalized": false,
  "data": {
    "root": "0x22d80095a28445aa150dd7c96ddedb40b1f177e3c71b1f9e95f13fea257964c4"
  }
}

With the fix, the same call should return "finalized": true.

curl http://localhost:3500/eth/v1/beacon/states/15061500/root | jq
{
  "execution_optimistic": false,
  "finalized": true,
  "data": {
    "root": "0x22d80095a28445aa150dd7c96ddedb40b1f177e3c71b1f9e95f13fea257964c4"
  }
}

Other notes for review
Please review commit by commit, with commit messages.

Acknowledgements

  • I have read CONTRIBUTING.md.
  • I have included a uniquely named changelog fragment file.
  • I have added a description with sufficient context for reviewers to understand this PR.
  • I have tested that my changes work as expected and I added a testing plan to the PR description (if applicable).

- `/eth/v1/beacon/states/{state_id}/root`,
- `/fork`,
- `/randao`,
- `/committees`,
- `/sync_committees`,
- `/finality_checkpoints`,
- `/validators`,
- `/validator_balances`,
- `/validator_identities`,
- `/pending_consolidations`,
- `/pending_deposits`,
- `/pending_partial_withdrawals`,
- `/proposer_lookahead`,
- `/eth/v2/debug/beacon/states/{state_id}`, and
- `/prysm/v1/beacon/states/{state_id}/validator_count`.
@nalepae nalepae changed the title Optimistic statediff Compute correctly the finalized field value in a bunch of beacon API endpoints. Aug 24, 2026
@nalepae
nalepae marked this pull request as ready for review August 24, 2026 10:54
james-prysm
james-prysm previously approved these changes Aug 24, 2026
@nalepae
nalepae enabled auto-merge August 24, 2026 14:18
@nalepae
nalepae dismissed stale reviews from Inspector-Butters and james-prysm via 8bbd1a9 August 25, 2026 10:24
@nalepae
nalepae added this pull request to the merge queue Aug 25, 2026
Merged via the queue into develop with commit 1531666 Aug 25, 2026
26 checks passed
@nalepae
nalepae deleted the optimistic-statediff branch August 25, 2026 12:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants