From 19765e13ebc270b08d677ae15fe4bd64798f8eaf Mon Sep 17 00:00:00 2001 From: mananuf Date: Tue, 7 Jul 2026 00:54:25 +0100 Subject: [PATCH 1/7] chore(spec): bump LEAN_SPEC_COMMIT_HASH to 4ca7d27 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index dbc158d..1b65920 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ NUM_NODES ?= 3 # Pinned leanSpec revision for spec fixtures. Must be defined before the test-spec/test-all # rules that reference it: Make expands a rule's prerequisites when it reads the rule, so a # definition placed after those rules would expand to empty in their prerequisites. -LEAN_SPEC_COMMIT_HASH := 43246bd6fd1497f5bbd875f4a9bdc5080902e830 +LEAN_SPEC_COMMIT_HASH := 4ca7d27e46b9b978c4f0a9b7e8eb6fd06be8286d help: ## Show help for each Makefile recipe @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' From ad262a512cf6c4c354a9c4941309668d35ec411c Mon Sep 17 00:00:00 2001 From: mananuf Date: Tue, 7 Jul 2026 04:56:01 +0100 Subject: [PATCH 2/7] fix(forkchoice): break equal-slot vote ties by canonical data root Mirror leanSpec #1181: an equivocator can cast two distinct votes at one slot. ExtractLatestAttestations now keeps, per validator, the vote with the highest (slot, attestation-data hash-tree-root), so the extracted LMD view is a pure function of the pool, independent of insertion order, and every node runs fork choice on the same set. Block production already applied the same canonical-root secondary sort key. --- internal/store/payloads.go | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/internal/store/payloads.go b/internal/store/payloads.go index a059461..7a705e6 100644 --- a/internal/store/payloads.go +++ b/internal/store/payloads.go @@ -1,6 +1,7 @@ package store import ( + "bytes" "sync" "github.com/geanlabs/gean/internal/types" @@ -156,6 +157,12 @@ func (pb *PayloadBuffer) ExtractLatestAttestations() map[uint64]*types.Attestati pb.mu.Lock() defer pb.mu.Unlock() + // Each validator keeps the vote with the highest (slot, canonical data root). + // An equivocator can cast two distinct votes at one slot; resolving that tie by + // the larger data root makes the extracted set a pure function of the pool, + // independent of insertion order, so every node runs fork choice on the same LMD + // view. dataRoot is the attestation-data hash-tree-root (the buffer key). + chosenRoot := make(map[uint64][32]byte) for _, dataRoot := range pb.order { entry, ok := pb.data[dataRoot] if !ok || !validPayloadEntry(entry) { @@ -167,18 +174,31 @@ func (pb *PayloadBuffer) ExtractLatestAttestations() map[uint64]*types.Attestati } participantLen := types.BitlistLen(proof.Participants) for vid := range participantLen { - if types.BitlistGet(proof.Participants, vid) { - existing, ok := result[vid] - if !ok || existing.Slot < entry.Data.Slot { - result[vid] = copyAttestationData(entry.Data) - } + if !types.BitlistGet(proof.Participants, vid) { + continue } + if existing, ok := result[vid]; ok && + !outranksVote(entry.Data.Slot, dataRoot, existing.Slot, chosenRoot[vid]) { + continue + } + result[vid] = copyAttestationData(entry.Data) + chosenRoot[vid] = dataRoot } } } return result } +// outranksVote reports whether (slotA, rootA) is the later LMD vote than +// (slotB, rootB): a higher slot wins, and an equal-slot tie breaks toward the +// larger canonical data root. +func outranksVote(slotA uint64, rootA [32]byte, slotB uint64, rootB [32]byte) bool { + if slotA != slotB { + return slotA > slotB + } + return bytes.Compare(rootA[:], rootB[:]) > 0 +} + func (pb *PayloadBuffer) PruneBelow(finalizedSlot uint64) int { if pb == nil { return 0 From e27dc7c44517066cab3c43b1b994d0e392a97bf8 Mon Sep 17 00:00:00 2001 From: mananuf Date: Tue, 7 Jul 2026 04:56:01 +0100 Subject: [PATCH 3/7] fix(blockprocessor): reject blocks beyond the future horizon Mirror leanSpec #1182: reject a block whose slot exceeds the store clock by more than one slot (BLOCK_TOO_FAR_IN_FUTURE), in on_block before the transition and signature check, so a far-future block cannot force an unbounded empty-slot walk. currentSlot = store.Time()/IntervalsPerSlot; the full-slot margin still admits an intended early block. The signatures spectest now sets the store clock to the block's slot, as the live node does before importing an on-time block. --- internal/blockprocessor/process.go | 12 ++++++++++++ internal/spectests/signatures_test.go | 7 +++++++ internal/store/errors.go | 2 ++ 3 files changed, 21 insertions(+) diff --git a/internal/blockprocessor/process.go b/internal/blockprocessor/process.go index a820973..ab2c3c8 100644 --- a/internal/blockprocessor/process.go +++ b/internal/blockprocessor/process.go @@ -45,6 +45,18 @@ func onBlockCore(s *store.ConsensusStore, signedBlock *types.SignedBlock, verify } } + // A block more than one slot beyond the store clock is outside the acceptance + // horizon: reject it before the transition and signature check so a far-future + // block cannot force an unbounded empty-slot walk. The full-slot margin still + // admits an intended early block. Time() is in intervals. + currentSlot := s.Time() / types.IntervalsPerSlot + if block.Slot > currentSlot+1 { + return &store.StoreError{ + Kind: store.ErrBlockTooFarInFuture, + Message: fmt.Sprintf("block slot %d beyond future horizon (current slot %d)", block.Slot, currentSlot), + } + } + if err := validateBlockAttestations(block); err != nil { return err } diff --git a/internal/spectests/signatures_test.go b/internal/spectests/signatures_test.go index 843294b..3fec95f 100644 --- a/internal/spectests/signatures_test.go +++ b/internal/spectests/signatures_test.go @@ -216,6 +216,13 @@ func runSignatureTest(t *testing.T, tt *sigTest) { signedBlock := tt.SignedBlock.toSignedBlock() + // Place the store clock at the block's slot, as the live node does before + // importing an on-time block; otherwise the future-horizon guard would reject a + // block whose slot leads the default-zero clock. + if signedBlock != nil && signedBlock.Block != nil { + s.SetTime(signedBlock.Block.Slot * types.IntervalsPerSlot) + } + // 5. Call OnBlock WITH signature verification. err = blockprocessor.OnBlock(s, signedBlock) diff --git a/internal/store/errors.go b/internal/store/errors.go index 8873990..1860574 100644 --- a/internal/store/errors.go +++ b/internal/store/errors.go @@ -38,4 +38,6 @@ const ( ErrSourceNotAncestorOfTarget ErrTargetNotAncestorOfHead ErrAttestationSlotBeforeHead + ErrBlockTooFarInFuture + ErrHeadNotDescendantOfFinalized ) From b625e80dd2aaa74b294d87efed5afd07f974a022 Mon Sep 17 00:00:00 2001 From: mananuf Date: Tue, 7 Jul 2026 04:56:01 +0100 Subject: [PATCH 4/7] fix(attestation): reject votes whose head is off the finalized subtree Mirror leanSpec #1179: attestation admission now rejects a vote whose head does not descend from the finalized block (HEAD_NOT_DESCENDANT_OF_FINALIZED), mirroring the prune predicate so a re-gossiped below-finalized aggregate cannot re-enter the pool after pruning dropped it. --- internal/attestation/validate.go | 10 ++++++++++ internal/attestation/validate_test.go | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/internal/attestation/validate.go b/internal/attestation/validate.go index d6ece1e..89d3f68 100644 --- a/internal/attestation/validate.go +++ b/internal/attestation/validate.go @@ -60,6 +60,10 @@ func errTargetNotAncestorOfHead() error { return &store.StoreError{Kind: store.ErrTargetNotAncestorOfHead, Message: "target checkpoint is not an ancestor of head"} } +func errHeadNotDescendantOfFinalized() error { + return &store.StoreError{Kind: store.ErrHeadNotDescendantOfFinalized, Message: "head checkpoint does not descend from the finalized block"} +} + func ValidateAttestationData(s *store.ConsensusStore, data *types.AttestationData) error { if err := validateDataShape(data); err != nil { return err @@ -99,6 +103,12 @@ func ValidateAttestationData(s *store.ConsensusStore, data *types.AttestationDat if !checkpointIsAncestor(s, data.Target, data.Head) { return errTargetNotAncestorOfHead() } + // Fork choice only ever descends from the finalized block, so an orphaned head + // carries no weight. Rejecting it at admission mirrors the prune predicate and + // keeps a re-gossiped below-finalized aggregate from re-entering the pool. + if finalized := s.LatestFinalized(); finalized != nil && !checkpointIsAncestor(s, finalized, data.Head) { + return errHeadNotDescendantOfFinalized() + } // A vote cannot have observed its head before that head existed. This lower // bound also keeps the wire slot clear of the 2**64 interval-multiply overflow. if data.Slot < data.Head.Slot { diff --git a/internal/attestation/validate_test.go b/internal/attestation/validate_test.go index a113140..9931e30 100644 --- a/internal/attestation/validate_test.go +++ b/internal/attestation/validate_test.go @@ -29,6 +29,9 @@ func insertValidationHeaders(s *store.ConsensusStore) { s.InsertBlockHeader([32]byte{1}, &types.BlockHeader{Slot: 3}) s.InsertBlockHeader([32]byte{2}, &types.BlockHeader{Slot: 4, ParentRoot: [32]byte{1}}) s.InsertBlockHeader([32]byte{3}, &types.BlockHeader{Slot: 5, ParentRoot: [32]byte{2}}) + // Anchor finalization at the chain base so a valid head descends from it; the + // finalized-descendant admission check needs a reachable finalized block. + s.SetLatestFinalized(&types.Checkpoint{Root: [32]byte{1}, Slot: 3}) } func TestValidateAttestationDataAvailability(t *testing.T) { @@ -118,6 +121,22 @@ func TestValidateAttestationDataSlotMismatches(t *testing.T) { } } +func TestValidateAttestationDataHeadOffFinalized(t *testing.T) { + s := makeValidationStore() + s.SetTime(30) + insertValidationHeaders(s) + // Finalize a slot-4 fork block off the chain base. The head {3} descends from + // {1}, not from this finalized block, so admission must reject it. + s.InsertBlockHeader([32]byte{9}, &types.BlockHeader{Slot: 4, ParentRoot: [32]byte{1}}) + s.SetLatestFinalized(&types.Checkpoint{Root: [32]byte{9}, Slot: 4}) + + err := attestation.ValidateAttestationData(s, makeValidAttestationData()) + se, ok := err.(*store.StoreError) + if !ok || se.Kind != store.ErrHeadNotDescendantOfFinalized { + t.Fatalf("error=%v, want ErrHeadNotDescendantOfFinalized", err) + } +} + func TestValidateAttestationDataSlotBeforeHead(t *testing.T) { s := makeValidationStore() s.SetTime(30) From 24d6621c27a0238e608a9312ad400a0b750a9b52 Mon Sep 17 00:00:00 2001 From: mananuf Date: Tue, 7 Jul 2026 04:56:01 +0100 Subject: [PATCH 5/7] fix(statetransition): reject malformed justification state with typed errors Mirror leanSpec #1178: reject a zero-validator registry (EMPTY_VALIDATOR_REGISTRY) instead of silently no-oping, and reject a justification vote bitlist whose length is not len(justifications_roots) * validator_count (JUSTIFICATION_VOTES_LENGTH_MISMATCH) rather than reading past or short of a segment. The existing zero-root check is retained, in spec order. --- internal/statetransition/attestations.go | 9 ++++++++- internal/statetransition/errors.go | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/statetransition/attestations.go b/internal/statetransition/attestations.go index e678932..589b679 100644 --- a/internal/statetransition/attestations.go +++ b/internal/statetransition/attestations.go @@ -41,7 +41,14 @@ func ProcessAttestations(state *types.State, attestations []*types.AggregatedAtt validatorCount := int(state.NumValidators()) if validatorCount == 0 { - return nil + return ErrEmptyValidatorRegistry + } + + // The flat vote bitlist segments into one block of validatorCount bits per + // tracked root; a mismatched length means the state is malformed and cannot be + // interpreted, so reject rather than read past or short of a segment. + if int(types.BitlistLen(state.JustificationsValidators)) != len(state.JustificationsRoots)*validatorCount { + return ErrJustificationVotesLengthMismatch } for _, root := range state.JustificationsRoots { diff --git a/internal/statetransition/errors.go b/internal/statetransition/errors.go index 5d8418f..6bf1bef 100644 --- a/internal/statetransition/errors.go +++ b/internal/statetransition/errors.go @@ -108,6 +108,10 @@ var ErrEmptyAggregationBits = fmt.Errorf("attestation aggregation bits have no s var ErrNoValidators = fmt.Errorf("state has no validators") var ErrZeroHashInJustificationRoots = fmt.Errorf("zero hash found in justifications_roots") + +var ErrEmptyValidatorRegistry = fmt.Errorf("state holds no validators to segment justification votes against") + +var ErrJustificationVotesLengthMismatch = fmt.Errorf("justifications_validators length does not equal justifications_roots count times validator count") var ErrMalformedState = fmt.Errorf("malformed state") var ErrMalformedBlock = fmt.Errorf("malformed block") From 905c49025bd42eda2bd427f884f816e217005977 Mon Sep 17 00:00:00 2001 From: mananuf Date: Tue, 7 Jul 2026 07:50:30 +0100 Subject: [PATCH 6/7] test(spectests): parse signed justifiability delta At pin 4ca7d27 the justifiability fixtures carry delta = slot - finalizedSlot as a signed value (negative for slots before the finalized boundary). Type the fixture field int64 and compute the cross-check delta with signed subtraction; gean's SlotIsJustifiableAfter already returns false for slot < finalizedSlot, so this is a fixture-parser catch-up only. --- internal/spectests/justifiability_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/spectests/justifiability_test.go b/internal/spectests/justifiability_test.go index f9eb508..a210318 100644 --- a/internal/spectests/justifiability_test.go +++ b/internal/spectests/justifiability_test.go @@ -28,8 +28,10 @@ type justifiabilityFixture struct { } type justifiabilityOutput struct { - Delta uint64 `json:"delta"` - IsJustifiable bool `json:"isJustifiable"` + // delta = slot - finalizedSlot, signed: a slot before the finalized boundary + // yields a negative delta. + Delta int64 `json:"delta"` + IsJustifiable bool `json:"isJustifiable"` } // TestSpecJustifiability walks the justifiability fixture directory and asserts @@ -62,7 +64,7 @@ func TestSpecJustifiability(t *testing.T) { for _, fx := range outer { fx := fx t.Run(base, func(t *testing.T) { - expectedDelta := fx.Slot - fx.FinalizedSlot + expectedDelta := int64(fx.Slot) - int64(fx.FinalizedSlot) if fx.Output.Delta != expectedDelta { t.Errorf("fixture delta inconsistent: slot=%d finalizedSlot=%d computedDelta=%d fixtureDelta=%d", fx.Slot, fx.FinalizedSlot, expectedDelta, fx.Output.Delta) From b3ca0f561c7139d86fc8b95b9bf6644b817c9b9f Mon Sep 17 00:00:00 2001 From: mananuf Date: Tue, 7 Jul 2026 07:50:30 +0100 Subject: [PATCH 7/7] test(spectests): reflect promoted votes into the known tracker The #1179-reworked store-pruning fixture expects a vote gossiped as "new" to be "known" after the next block step. The harness promoted the payload pool (PromoteNewToKnown) but left the fork-choice known tracker unchanged, so the just-promoted vote was absent from the tracker the attestationChecks read. Re-derive the known votes from the promoted pool, mirroring the Engine's next updateHead. --- internal/spectests/forkchoice_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/spectests/forkchoice_test.go b/internal/spectests/forkchoice_test.go index 48fff83..fdd267c 100644 --- a/internal/spectests/forkchoice_test.go +++ b/internal/spectests/forkchoice_test.go @@ -499,6 +499,14 @@ func runForkChoiceTest(t *testing.T, tt *fcTest) { // Promote new payloads to known (so next updateHead sees them). s.PromoteNewToKnown() + // Reflect the just-promoted votes into the fork-choice known tracker, + // as the Engine's next updateHead re-derives known votes from the + // promoted pool. Without this, a vote gossiped as "new" is promoted in + // the payload pool but stays absent from the tracker the checks read. + for vid, data := range s.ExtractLatestKnownAttestations() { + fc.SetKnownVote(vid, data.Head.Root, data.Slot, data) + } + // Validate checks if present. if step.Checks != nil { validateChecks(t, i, step.Checks, s, fc, labelRoots)