Skip to content

Commit 11ee711

Browse files
committed
fix(stf): keep the 3SF-mini justifiability rule under the heartbeat fork choice
The heartbeat work had dropped `slot_is_justifiable_after` and collapsed finalization to `source.slot + 1 == target.slot`. Restore the rule: the two-tier fork choice changes which head each interval computes, not which slots may be justified, so the two are independent. Unjustifiable slots exist to funnel votes. Under high latency validators otherwise spread across many targets and none reaches a supermajority; the 4-interval grid does not remove that pressure. Restored at the four sites that had diverged: - `is_valid_vote` filters targets that are not justifiable after the finalized slot. Its doc comment had kept listing the check, so the code and the contract had drifted apart. - `try_finalize` requires no justifiable slot strictly between source and target, rather than plain adjacency. - `get_attestation_target_with_checkpoints` walks the target back into the justifiable range again, making `finalized` a live parameter once more. - The block builder mirrors both: `finalizes` uses the gap scan and `entry_passes_filters` rejects `target_not_justifiable`. The test added to pin the adjacency rule still holds under 3SF-mini, for the reason the rule gives rather than by adjacency, and is renamed to say so. stf_spectests returns to 74/74 and forkchoice_spectests to 97/122. The 25 remaining failures are the interval grid and the two-tier head, which the fixtures do not encode yet.
1 parent 2455ce8 commit 11ee711

3 files changed

Lines changed: 99 additions & 28 deletions

File tree

crates/blockchain/src/block_builder.rs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use std::{
1818
use ethlambda_crypto::{aggregate_proofs, signature::ValidatorPublicKey};
1919
use ethlambda_state_transition::{
2020
attestation_data_matches_chain, is_heartbeat_committee_member, justified_slots_ops,
21-
process_block, process_slots,
21+
process_block, process_slots, slot_is_justifiable_after,
2222
};
2323
use ethlambda_types::{
2424
ShortRoot,
@@ -480,14 +480,16 @@ impl ProjectedState {
480480
let total = prior_count + new_voters.len();
481481
let crosses_2_3 = 3 * total >= 2 * validator_count;
482482

483-
// The simple BFT finality condition finalizes the source when it lies past
484-
// the finalized boundary (a source at or behind it is already final and must
485-
// not re-finalize) and the target is its immediate successor, so the two are
486-
// consecutive justified checkpoints in the projected post-state. Mirrors
483+
// 3SF-mini finalization requires the source to lie past the finalized
484+
// boundary (a source at or behind it is already final and must not
485+
// re-finalize) and no slot strictly between source.slot and target.slot
486+
// to still be justifiable (so source and target are consecutive
487+
// justified checkpoints in the projected post-state). Mirrors
487488
// `try_finalize` in the state transition.
488489
let finalizes = crosses_2_3
489490
&& att_data.source.slot > self.finalized_slot
490-
&& att_data.source.slot + 1 == att_data.target.slot;
491+
&& (att_data.source.slot + 1..att_data.target.slot)
492+
.all(|s| !slot_is_justifiable_after(s, self.finalized_slot));
491493

492494
let effect = if is_genesis_self_vote(att_data) || !crosses_2_3 {
493495
EntryEffect::Builds
@@ -523,8 +525,9 @@ impl ProjectedState {
523525
///
524526
/// Mirrors `state_transition::is_valid_vote`: the entry's head must be
525527
/// known, its source must be justified, its (source, target) must match
526-
/// the candidate-block chain view, `target.slot > source.slot`, and target
527-
/// must not already be justified. The genesis self-vote
528+
/// the candidate-block chain view, `target.slot > source.slot`, target
529+
/// must not already be justified, and target must be a justifiable slot
530+
/// relative to the projected finalized slot. The genesis self-vote
528531
/// (source == target == slot 0) is exempt from the `target.slot >
529532
/// source.slot` and `target_already_justified` checks since fork-choice
530533
/// bootstrapping needs it; STF will silently drop it, but it carries
@@ -561,6 +564,11 @@ impl ProjectedState {
561564
{
562565
return Err("target_already_justified");
563566
}
567+
if !is_genesis_self_vote
568+
&& !slot_is_justifiable_after(att_data.target.slot, self.finalized_slot)
569+
{
570+
return Err("target_not_justifiable");
571+
}
564572
Ok(())
565573
}
566574
}

crates/blockchain/src/store.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet};
33
use ethlambda_crypto::signature::{ValidatorPublicKey, ValidatorSignature};
44
use ethlambda_state_transition::{
55
effective_heartbeat_committee_size, is_heartbeat_committee_member, is_proposer,
6+
slot_is_justifiable_after,
67
};
78
use ethlambda_storage::{ForkCheckpoints, Store};
89
use ethlambda_types::{
@@ -1052,15 +1053,11 @@ pub fn get_attestation_target(store: &Store) -> Checkpoint {
10521053
/// Note: the walk-back still starts from `store.head()` (the pre-import head), not
10531054
/// the new block. This is correct because the new block is only 1 slot ahead with less than 2/3 of votes — the
10541055
/// walk-back immediately reaches the same chain. The important fix is using the
1055-
/// post-state justified for the clamping guard.
1056+
/// post-state justified/finalized for the justifiability check and clamping guard.
10561057
pub fn get_attestation_target_with_checkpoints(
10571058
store: &Store,
10581059
justified: Checkpoint,
1059-
// Unused under the simple BFT finality condition: every slot is justifiable
1060-
// now, so the target no longer needs a justifiability walk-back. Kept on the
1061-
// signature because callers resolve both checkpoints from the same
1062-
// post-state and the pair reads as one unit.
1063-
_finalized: Checkpoint,
1060+
finalized: Checkpoint,
10641061
) -> Checkpoint {
10651062
// Start from current head
10661063
let mut target_block_root = store.head().unwrap();
@@ -1091,6 +1088,22 @@ pub fn get_attestation_target_with_checkpoints(
10911088
}
10921089
}
10931090

1091+
let finalized_slot = finalized.slot;
1092+
1093+
// Ensure target is in justifiable slot range
1094+
//
1095+
// Walk back until we find a slot that satisfies justifiability rules
1096+
// relative to the latest finalized checkpoint.
1097+
while target_header.slot > finalized_slot
1098+
&& !slot_is_justifiable_after(target_header.slot, finalized_slot)
1099+
{
1100+
target_block_root = target_header.parent_root;
1101+
target_header = store
1102+
.get_block_header(&target_block_root)
1103+
.expect("parent block exists")
1104+
.unwrap();
1105+
}
1106+
10941107
// Guard: clamp target to justified (not in the spec).
10951108
//
10961109
// The spec's walk-back has no lower bound, so it can produce attestations

crates/blockchain/state_transition/src/lib.rs

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -485,17 +485,22 @@ fn is_valid_vote(state: &State, data: &AttestationData) -> bool {
485485
return false;
486486
}
487487

488+
// Ensure the target falls on a slot that can be justified after the finalized one.
489+
if !slot_is_justifiable_after(target.slot, state.latest_finalized.slot) {
490+
return false;
491+
}
492+
488493
true
489494
}
490495

491496
/// Attempt to advance finalization from source to target.
492497
///
493498
/// Finalization advances only when the source lies past the old finalized point
494-
/// and the target is the source's immediate successor, so the two are
495-
/// consecutive justified checkpoints. A source at or behind the finalized
496-
/// boundary is already final: it may justify a newer target, but it must not
497-
/// re-finalize. When finalization advances, shifts the justified_slots window
498-
/// and prunes stale justifications.
499+
/// and there are no justifiable slots between source.slot and target.slot
500+
/// (exclusive). A source at or behind the finalized boundary is already final:
501+
/// it may justify a newer target, but it must not re-finalize or scan below the
502+
/// finalized boundary. When finalization advances, shifts the justified_slots
503+
/// window and prunes stale justifications.
499504
fn try_finalize(
500505
state: &mut State,
501506
source: Checkpoint,
@@ -509,10 +514,10 @@ fn try_finalize(
509514
return;
510515
}
511516

512-
// Consider whether finalization can advance. Every slot is justifiable now,
513-
// so "no justifiable slot strictly between source and target" collapses to
514-
// the two being adjacent.
515-
if source.slot + 1 != target.slot {
517+
// Consider whether finalization can advance.
518+
if ((source.slot + 1)..target.slot)
519+
.any(|slot| slot_is_justifiable_after(slot, state.latest_finalized.slot))
520+
{
516521
metrics::inc_finalizations("error");
517522
return;
518523
}
@@ -621,6 +626,47 @@ pub fn attestation_data_matches_chain(
621626
&& historical_block_hashes[head_slot] == data.head.root
622627
}
623628

629+
/// Checks if the slot is a valid candidate for justification after a given finalized slot.
630+
///
631+
/// According to the 3SF-mini specification, a slot is justifiable if its
632+
/// distance (`delta`) from the last finalized slot is:
633+
/// 1. Less than or equal to 5.
634+
/// 2. A perfect square (e.g., 9, 16, 25...).
635+
/// 3. A pronic number (of the form x^2 + x, e.g., 6, 12, 20...).
636+
///
637+
/// See https://github.com/ethereum/research/blob/c003fe1c1a785797e7b53e3cbf9569b989be6e93/3sf-mini/consensus.py#L52-L54
638+
/// for the 3SF-mini reference.
639+
///
640+
/// For why we have unjustifiable slots, consider that in high-latency
641+
/// scenarios, validators may vote for many different slots, making none of them
642+
/// reach the supermajority threshold. By having unjustifiable slots, we can
643+
/// funnel votes towards only some slots, increasing finalization chances.
644+
pub fn slot_is_justifiable_after(slot: u64, finalized_slot: u64) -> bool {
645+
let Some(delta) = slot.checked_sub(finalized_slot) else {
646+
// Candidate slot must not be before finalized slot
647+
return false;
648+
};
649+
// Rule 1: The first 5 slots after finalization are always justifiable.
650+
//
651+
// Examples: delta = 0, 1, 2, 3, 4, 5
652+
delta <= 5
653+
// Rule 2: Slots at perfect square distances are justifiable.
654+
//
655+
// Examples: delta = 1, 4, 9, 16, 25, 36, 49, 64, ...
656+
// Check: integer square root squared equals delta
657+
|| delta.isqrt().pow(2) == delta
658+
// Rule 3: Slots at pronic number distances are justifiable.
659+
//
660+
// Pronic numbers have the form n(n+1): 2, 6, 12, 20, 30, 42, 56, ...
661+
// Mathematical insight: For pronic delta = n(n+1), we have:
662+
// 4*delta + 1 = 4n(n+1) + 1 = (2n+1)^2
663+
// Check: 4*delta+1 is an odd perfect square
664+
|| delta
665+
.checked_mul(4)
666+
.and_then(|v| v.checked_add(1))
667+
.is_some_and(|val| val.isqrt().pow(2) == val && val % 2 == 1)
668+
}
669+
624670
#[cfg(test)]
625671
mod tests {
626672
use super::*;
@@ -706,10 +752,11 @@ mod tests {
706752
}
707753

708754
#[test]
709-
fn finalization_requires_adjacent_source_and_target() {
710-
// The simple BFT condition: only consecutive checkpoints finalize. Pinned
711-
// here because the 3SF-mini justifiability rules that used to gate this
712-
// are gone.
755+
fn finalization_blocked_by_justifiable_slot_between_source_and_target() {
756+
// Source and target must be *consecutive justified checkpoints*, which
757+
// under 3SF-mini means no slot strictly between them is still justifiable
758+
// relative to the finalized boundary. Here slot 4 is (delta = 4 <= 5), so
759+
// the pair is not consecutive and finalization must not advance.
713760
let mut state = State::from_genesis(0, make_validators(4));
714761
state.latest_finalized = Checkpoint {
715762
root: H256::ZERO,
@@ -732,7 +779,10 @@ mod tests {
732779
&mut justifications,
733780
&root_to_slot,
734781
);
735-
assert_eq!(state.latest_finalized.slot, 0, "gap must not finalize");
782+
assert_eq!(
783+
state.latest_finalized.slot, 0,
784+
"justifiable slot between source and target must not finalize"
785+
);
736786
}
737787

738788
fn make_validators(n: usize) -> Vec<Validator> {

0 commit comments

Comments
 (0)