Skip to content

feat(validator): source votes from the head chain's justified checkpoint#1166

Open
MegaRedHand wants to merge 5 commits into
leanEthereum:mainfrom
lambdaclass:vote-source-from-head-state
Open

feat(validator): source votes from the head chain's justified checkpoint#1166
MegaRedHand wants to merge 5 commits into
leanEthereum:mainfrom
lambdaclass:vote-source-from-head-state

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What

Change the attestation vote source in produce_attestation_data from the store's latest justified (store.latest_justified) to the latest justified on the current head's state (store.states[store.head].latest_justified).

Why

A validator's vote source should name the justified checkpoint on the chain its vote extends, not the store's global view.

The store can advance its justified checkpoint from a minority fork that the head never extended (see test_attestation_source_divergence.py). When that happens the store's justified and the head chain's justified diverge, and sourcing from the store would place the vote's source off what the head chain actually justified.

            genesis
              |
            common(1)
            /        \
      block_2(2)     fork_4(4)   <- justifies common (slot 1): 3/4 votes
          |
      block_3(3)                 <- head; its state justifies nothing (slot 0)

store.latest_justified        = common (slot 1)   <- advanced by the fork_4 minority branch
head_state.latest_justified   = genesis (slot 0)  <- what the head chain actually justified

After this change a vote produced on block_3 sources from slot 0 (the head chain), not slot 1 (the store).

The liveness failure this prevents

store.latest_justified is always an ancestor of the head, but a minority fork can push it to a higher slot than the head chain's own state has justified. Finalization finalizes the source of the justifying votes, and the state transition drops any vote whose source slot the including chain's state has not itself marked justified. Sourcing new votes from that higher, fork-driven slot therefore stalls the canonical chain. Step by step:

genesis(0) - c1(1) - c2(2) - c3(3) - c4(4)   canonical head chain (head = c4)
                               |
                               k4(4)          minority fork off c3
  1. The canonical chain becomes head. Blocks c1..c4 gather the bulk of the votes, so fork choice keeps the head on this branch.
  2. A fork advances justification but not the head. Fork block k4 carries 2/3 votes that justify slot 3 (c3, on the shared trunk). store.latest_justified jumps to slot 3, yet k4 is too light to become head. The canonical blocks never included those votes, so c4's state still has justified = slot 2.
  3. The canonical chain finalizes, but its justification stays behind the fork. Earlier, correctly-sourced votes let the canonical chain finalize slot 1 and justify slot 2. Its justified slot (2) never climbs ahead of the slot the fork justified (3).
  4. The chain is stuck. Under the old rule every new vote now sources from store.latest_justified = slot 3. To finalize its next checkpoint (slot 2) the canonical chain needs votes whose source is slot 2, but c4's state never recorded slot 3 as justified, so those slot-3-sourced votes are dropped (source not justified on this state, or source after target). The consecutive source -> target link that finalization requires can never form, so the canonical chain stalls until it manages to overcome the slot the fork justified.

Sourcing from the head state's justified breaks the stall: every vote anchors on a slot the canonical chain itself has justified (slot 2 here), so finalization keeps advancing along the head chain's own ladder, finalizing a slot below the fork's justified slot.

Genesis handling

The raw genesis state holds a zero-root placeholder for its justified checkpoint; the state transition rebases that to the real genesis root on the first block. A vote cast directly on the genesis head normalizes the placeholder the same way, so the source always names a real block (otherwise validate_attestation would reject it with UNKNOWN_SOURCE_BLOCK).

Testing

  • All 84 affected node unit tests pass.
  • All 542 consensus test vectors pass and are byte-identical across hash seeds.
  • just check passes (lint, format, typecheck, spell, mdformat).

@tcoratger tcoratger left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Really nice fix, and the writeup in the description is super clear — the step-by-step liveness explanation made this easy to follow 🙏

One thing that I noticed: since this changes behavior inside a fork module, the convention here is to pin it with a consensus vector under tests/consensus/, not just a node unit test. Right now I don't think anything actually exercises the divergence this PR fixes — so it'd be easy for it to quietly regress later. Left a note inline with a concrete idea for a vector that would lock it in.

Nothing blocking — just a couple of small inline thoughts (a docstring line, a tiny edge-case, and the coverage idea). Thanks for this! 🙂

Comment thread src/lean_spec/spec/forks/lstar/validator_duties.py
Comment thread tests/node/validator/test_service.py
A validator's attestation source should name the justified checkpoint on the
chain its vote extends, not the store's global view. The store can advance its
justified checkpoint from a minority fork the head never extended, so the two
diverge; sourcing from the store would put the vote's source off the head chain.

The raw genesis state holds a zero-root placeholder for its justified checkpoint,
which the state transition rebases to the real genesis root on the first block. A
vote cast directly on the genesis head normalizes the placeholder the same way, so
the source always names a real block.
Add a consensus vector that routes an honest gossip vote through
produce_attestation_data while the store's justified diverges from the head
chain's. The vote must source from the head chain's justified (genesis, slot 0),
not the store's (common, slot 1).

To make a single attestation's source assertable, extend AttestationCheck to
read the raw signature pool (location "signatures") and add a source-root check.
Collapsing the message dropped the "_aggregated_payloads" suffix for the new and
known pools. Name each pool by its actual store field so the not-found message
reads accurately for all three locations.
@MegaRedHand MegaRedHand force-pushed the vote-source-from-head-state branch from 7e038a1 to 081a33f Compare June 19, 2026 19:21
MegaRedHand added a commit to lambdaclass/ethlambda that referenced this pull request Jun 19, 2026
…447)

## Summary

Validators sourced their vote's `source` checkpoint from the store's
global `latest_justified`, a **highest-slot-wins maximum**. When a
minority fork justifies a slot the head chain never extended, that
maximum latches onto an **off-head sibling** (always an ancestor of the
head, but possibly at a *higher* slot than the head chain's own state
has justified). Sourcing a vote from that higher slot pins every target
above the canonical chain's justification ladder, so the canonical chain
cannot finalize until it climbs past whatever slot the minority fork
justified, and block production stalls (the "did not converge" stall).

This sources the vote's `source` from the **head state's**
`latest_justified` instead, which always reflects what the head chain
itself justified, so source and target stay on the same ladder and
finalization can advance.

## Matches leanSpec PR #1166

This is the **same change** leanSpec is landing in [PR
#1166](leanEthereum/leanSpec#1166), including
its genesis handling, so it aligns ethlambda with the spec direction
(the prior `store.latest_justified` sourcing came from leanSpec #595).

## What changed

`produce_attestation_data` (vote production only):

| | Before | After |
|---|---|---|
| `source` | `store.latest_justified()` (global max) |
`head_state.latest_justified` (head chain's own) |
| target clamp | `store.latest_justified()` | same head-state justified
(kept consistent with source) |
| genesis placeholder | n/a | zero-root justified rebased to the head
(genesis) root |

- The store's `latest_justified` checkpoint is **left untouched**.
- `get_attestation_target(store)` is unchanged and still reads the store
checkpoints. It is what the forkchoice spec-test harness uses to
validate `attestationTargetSlot`, so **spec-test behavior is
unchanged**.
- **Genesis handling:** the raw genesis state carries a zero-root
justified placeholder that the state transition rebases to the real
genesis root on the first block. A vote cast directly on the genesis
head normalizes the placeholder the same way, else
`validate_attestation_data` would reject it with `UnknownSourceBlock`.

## Testing

- `produce_attestation_data_sources_from_head_state_not_store`: with the
store's global justified latched onto a higher off-head sibling, the
produced source follows the head state's on-chain justified.
- `produce_attestation_data_normalizes_genesis_placeholder_source`: a
genesis-head vote rebases the zero-root placeholder to the genesis root
and passes `validate_attestation_data`.
- `cargo test -p ethlambda-blockchain --lib` — 42 passed.
- `cargo fmt --all -- --check`, `cargo clippy -p ethlambda-blockchain
--all-targets -- -D warnings` — clean.
- Forkchoice spec fixtures were not downloaded in this environment; the
change does not touch `get_attestation_target`, the only attestation
path the spec harness exercises. CI will confirm against the full
vectors.

https://claude.ai/code/session_01783X9yFrkXfA7uY1cyL8E4
if justified_source.root == Bytes32.zero():
justified_source = Checkpoint(root=store.head, slot=justified_source.slot)

# Sanity check: the source must be after target.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Source before the target no?

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