Skip to content
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
Expand Down
10 changes: 10 additions & 0 deletions internal/attestation/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions internal/attestation/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions internal/blockprocessor/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
8 changes: 8 additions & 0 deletions internal/spectests/forkchoice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions internal/spectests/justifiability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions internal/spectests/signatures_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 8 additions & 1 deletion internal/statetransition/attestations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions internal/statetransition/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
2 changes: 2 additions & 0 deletions internal/store/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,6 @@ const (
ErrSourceNotAncestorOfTarget
ErrTargetNotAncestorOfHead
ErrAttestationSlotBeforeHead
ErrBlockTooFarInFuture
ErrHeadNotDescendantOfFinalized
)
30 changes: 25 additions & 5 deletions internal/store/payloads.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package store

import (
"bytes"
"sync"

"github.com/geanlabs/gean/internal/types"
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down