Skip to content

Commit 0078a10

Browse files
committed
fix(blockchain): source attestations from the head state's justified
Validators sourced their vote's `source` checkpoint from the store's global `latest_justified`, which is a highest-slot-wins maximum. When a minority fork justifies a slot the head chain never saw, that maximum latches onto an off-head sibling. Voting with an off-chain source makes every head-chain target fail `is_valid_vote` (the target no longer descends from the source), so the head can never re-justify and block production stalls. Source from the head state's `latest_justified` instead, which always lies on the head's own chain. The target walk-back is fed the same head-state justified so the emitted source and target stay mutually consistent. The store's `latest_justified` is left untouched; only vote production changes. `get_attestation_target` (used by the forkchoice spec-test harness) keeps reading the store checkpoints, so spec behavior is unchanged. This mirrors the head-state-driven treatment finalized already received in #437, but applied to vote production rather than the stored checkpoint. It is a deliberate divergence from leanSpec, which sources from `store.latest_justified` (leanSpec PR #595). Claude-Session: https://claude.ai/code/session_01783X9yFrkXfA7uY1cyL8E4
1 parent 02ff520 commit 0078a10

1 file changed

Lines changed: 64 additions & 8 deletions

File tree

crates/blockchain/src/store.rs

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -697,15 +697,26 @@ pub fn get_attestation_target_with_checkpoints(
697697

698698
/// Produce attestation data for the given slot.
699699
///
700-
/// The source comes from the store's global `latest_justified` checkpoint.
701-
/// When the store's justified has advanced past the head state (a minority
702-
/// fork justified a slot the head chain hasn't seen yet), the next block
703-
/// produced on the head chain is expected to close the gap via the
704-
/// fixed-point attestation loop in `build_block`.
700+
/// The source is the **head state's** latest justified checkpoint, which always
701+
/// lies on the head's own chain. This deliberately diverges from leanSpec, which
702+
/// sources from the store's global `latest_justified` (see
703+
/// <https://github.com/leanEthereum/leanSpec/pull/595>).
705704
///
706-
/// See: <https://github.com/leanEthereum/leanSpec/pull/595>
705+
/// The store's justified is a highest-slot-wins maximum that can latch onto an
706+
/// off-head sibling justified by a minority fork. Voting with such an off-chain
707+
/// source makes every head-chain target fail `is_valid_vote` (the target no
708+
/// longer descends from the source), so the head can never re-justify and block
709+
/// production stalls. Sourcing from the head state keeps the source and target
710+
/// on the same chain, letting justification advance again.
711+
///
712+
/// The target walk-back is fed the same head-state justified so its clamp guard
713+
/// stays consistent with the source we emit.
707714
pub fn produce_attestation_data(store: &Store, slot: u64) -> AttestationData {
708715
let head_root = store.head();
716+
let source = store
717+
.get_state(&head_root)
718+
.expect("head state exists")
719+
.latest_justified;
709720

710721
let head_checkpoint = Checkpoint {
711722
root: head_root,
@@ -715,13 +726,14 @@ pub fn produce_attestation_data(store: &Store, slot: u64) -> AttestationData {
715726
.slot,
716727
};
717728

718-
let target_checkpoint = get_attestation_target(store);
729+
let target_checkpoint =
730+
get_attestation_target_with_checkpoints(store, source, store.latest_finalized());
719731

720732
AttestationData {
721733
slot,
722734
head: head_checkpoint,
723735
target: target_checkpoint,
724-
source: store.latest_justified(),
736+
source,
725737
}
726738
}
727739

@@ -1206,6 +1218,50 @@ mod tests {
12061218
Store::from_anchor_state(backend, genesis_state)
12071219
}
12081220

1221+
/// The produced attestation source must track the head state's justified
1222+
/// checkpoint, not the store's global `latest_justified`. When the store's
1223+
/// justified has latched onto a higher, off-head sibling (a minority fork),
1224+
/// sourcing from it would put the vote's source off the head chain and stall
1225+
/// re-justification; sourcing from the head state keeps it on-chain.
1226+
#[test]
1227+
fn produce_attestation_data_sources_from_head_state_not_store() {
1228+
let mut store = new_test_store();
1229+
let genesis = store.head();
1230+
1231+
// Head chain: genesis(0) <- a(1) <- b(2), with `b` as head.
1232+
let a = H256([1u8; 32]);
1233+
let b = H256([2u8; 32]);
1234+
insert_test_block(&mut store, a, 1, genesis);
1235+
insert_test_block(&mut store, b, 2, a);
1236+
1237+
// Head state justified `a` (slot 1), which lies on the head's chain.
1238+
let head_justified = Checkpoint { root: a, slot: 1 };
1239+
let mut head_state = State::from_genesis(1000, vec![]);
1240+
head_state.latest_justified = head_justified;
1241+
store.insert_state(b, head_state);
1242+
1243+
// Store's global justified latched onto a higher, off-head checkpoint,
1244+
// as it would after a minority fork justified a slot the head never saw.
1245+
let off_head_justified = Checkpoint {
1246+
root: H256([9u8; 32]),
1247+
slot: 5,
1248+
};
1249+
store.update_checkpoints(ForkCheckpoints::new(b, Some(off_head_justified), None));
1250+
store.set_time(2 * INTERVALS_PER_SLOT);
1251+
1252+
let data = produce_attestation_data(&store, 2);
1253+
1254+
assert_eq!(
1255+
data.source, head_justified,
1256+
"source should follow the head state's justified checkpoint"
1257+
);
1258+
assert_ne!(
1259+
data.source,
1260+
store.latest_justified(),
1261+
"source must not be the store's off-head global justified"
1262+
);
1263+
}
1264+
12091265
/// leanSpec #833: a vote whose head sits on a sibling fork of the target
12101266
/// must be rejected by gossip validation, even though every slot and
12111267
/// availability check passes.

0 commit comments

Comments
 (0)