Skip to content

beacon/blsync: Add comprehensive finality test coverage #32196

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 210 additions & 10 deletions beacon/blsync/block_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,189 @@ func TestBlockSync(t *testing.T) {
expHeadBlock(testBlock2)
}

// TestBlockSyncFinality tests the beacon block sync's handling of finality updates.
//
// Beacon chain finality works as follows:
// - An "attested" header is the latest block that has been attested to by validators
// - A "finalized" header is a block that has been finalized (cannot be reverted)
// - ChainHeadEvents should include the finalized block hash when finality data is available
// - This enables the execution client to know which blocks are safe from reorgs
func TestBlockSyncFinality(t *testing.T) {
ht := &testHeadTracker{}
blockSync := newBeaconBlockSync(ht)
headCh := make(chan types.ChainHeadEvent, 16)
blockSync.SubscribeChainHead(headCh)
ts := sync.NewTestScheduler(t, blockSync)
ts.AddServer(testServer1, 1)
ts.AddServer(testServer2, 1)

// expChainHeadEvent is a helper function that validates ChainHeadEvent emissions.
// It checks that:
// 1. An event is emitted when expected (or not emitted when expHead is nil)
// 2. The event contains the correct execution block number
// 3. The event contains the expected finalized hash (or empty hash when no finality)
expChainHeadEvent := func(expHead *types.BeaconBlock, expFinalizedHash common.Hash) {
t.Helper()
var event types.ChainHeadEvent
var hasEvent bool
select {
case event = <-headCh:
hasEvent = true
default:
}

if expHead == nil {
if hasEvent {
t.Errorf("Expected no chain head event, but got one with block number %d", event.Block.NumberU64())
}
return
}

if !hasEvent {
t.Errorf("Expected chain head event with block number %d, but got none", expHead.Header().Slot)
return
}

expPayload, err := expHead.ExecutionPayload()
if err != nil {
t.Fatalf("expHead.ExecutionPayload() failed: %v", err)
}

if event.Block.NumberU64() != expPayload.NumberU64() {
t.Errorf("Wrong head block number, expected %d, got %d", expPayload.NumberU64(), event.Block.NumberU64())
}

if event.Finalized != expFinalizedHash {
t.Errorf("Wrong finalized hash, expected %x, got %x", expFinalizedHash, event.Finalized)
}
}

// ═══════════════════════════════════════════════════════════════════════════════════
// Test Scenario 1: Basic finality with proper finality update
// ═══════════════════════════════════════════════════════════════════════════════════
// This tests the normal case where we have both an attested block (testBlock1) and
// a finalized block (testBlock2). The ChainHeadEvent should include the finalized
// block's execution hash, indicating to the execution client that testBlock2 is safe.

head1 := blockHeadInfo(testBlock1)
ht.prefetch = head1
ht.validated.Header = testBlock1.Header()

// Configure finality update: testBlock1 is attested, testBlock2 is finalized
ht.finalized.Attested.Header = testBlock1.Header()
ht.finalized.Finalized.Header = testBlock2.Header()
ht.finalized.Finalized.PayloadHeader = createTestExecutionHeader(testBlock2)

// Simulate the block sync process
ts.ServerEvent(sync.EvNewHead, testServer1, head1)
ts.Run(1, testServer1, sync.ReqBeaconBlock(head1.BlockRoot))
ts.RequestEvent(request.EvResponse, ts.Request(1, 1), testBlock1)
ts.AddAllowance(testServer1, 1)
ts.Run(2)

// Verify that ChainHeadEvent includes the finalized block's execution hash
finalizedPayload, err := testBlock2.ExecutionPayload()
if err != nil {
t.Fatalf("Failed to get finalized payload: %v", err)
}
expFinalizedHash := finalizedPayload.Hash()
expChainHeadEvent(testBlock1, expFinalizedHash)

// ═══════════════════════════════════════════════════════════════════════════════════
// Test Scenario 2: No finality update available
// ═══════════════════════════════════════════════════════════════════════════════════
// This tests the case where we have a new head block but no finality information.
// The ChainHeadEvent should be emitted but with an empty finalized hash.

// Clear any pending events from the previous test
select {
case <-headCh:
default:
}

// Set up scenario: new head (testBlock2) but no finality update
ht.validated.Header = testBlock2.Header()
ht.finalized = types.FinalityUpdate{} // Explicitly clear finality data
head2 := blockHeadInfo(testBlock2)
ht.prefetch = head2

// Simulate block sync process
ts.ServerEvent(sync.EvNewHead, testServer1, head2)
ts.Run(3, testServer1, sync.ReqBeaconBlock(head2.BlockRoot))
ts.RequestEvent(request.EvResponse, ts.Request(3, 1), testBlock2)
ts.AddAllowance(testServer1, 1)
ts.Run(4)

// Verify ChainHeadEvent is emitted but with empty finalized hash
expChainHeadEvent(testBlock2, common.Hash{})

// ═══════════════════════════════════════════════════════════════════════════════════
// Test Scenario 3: Direct ValidatedFinality method testing
// ═══════════════════════════════════════════════════════════════════════════════════
// This tests the ValidatedFinality method directly to ensure it returns the correct
// finality update structure and availability flag.

// Clear any pending events
select {
case <-headCh:
default:
}

// Set up a proper finality update structure
ht.validated.Header = testBlock1.Header()
ht.finalized.Attested.Header = testBlock1.Header()
ht.finalized.Finalized.Header = testBlock2.Header()
ht.finalized.Finalized.PayloadHeader = createTestExecutionHeader(testBlock2)

// Test the ValidatedFinality method directly
finalityUpdate, hasFinalityUpdate := ht.ValidatedFinality()
if !hasFinalityUpdate {
t.Error("Expected finality update to be available")
}

if finalityUpdate.Attested.Header != testBlock1.Header() {
t.Error("Finality update attested header doesn't match expected testBlock1")
}

if finalityUpdate.Finalized.Header != testBlock2.Header() {
t.Error("Finality update finalized header doesn't match expected testBlock2")
}

// Test that the sync logic properly uses this finality update
// Since testBlock1 is already in cache, we can just run the sync logic
ts.Run(5)

// Verify that the finality information is properly included in the ChainHeadEvent
expChainHeadEvent(testBlock1, expFinalizedHash)
}

// createTestExecutionHeader creates a minimal ExecutionHeader for testing purposes.
//
// In production, ExecutionHeaders contain many fields (parent hash, state root, receipts root, etc.)
// but for testing beacon chain finality logic, we only need the block hash to verify that
// the correct finalized block is referenced in ChainHeadEvents.
//
// This simplified approach allows us to test the finality propagation logic without
// dealing with the complexity of constructing full execution payloads.
func createTestExecutionHeader(block *types.BeaconBlock) *types.ExecutionHeader {
payload, err := block.ExecutionPayload()
if err != nil {
panic(err)
}
// Create a minimal ExecutionHeader with only the block hash populated
// This is sufficient for testing finality hash propagation
execHeader := &deneb.ExecutionPayloadHeader{
BlockHash: [32]byte(payload.Hash()),
}
return types.NewExecutionHeader(execHeader)
}

// testHeadTracker is a mock implementation of the HeadTracker interface for testing.
// It allows tests to simulate different beacon chain states and finality conditions.
type testHeadTracker struct {
prefetch types.HeadInfo
validated types.SignedHeader
prefetch types.HeadInfo // The head info to return from PrefetchHead()
validated types.SignedHeader // The validated header for optimistic updates
finalized types.FinalityUpdate // The finality update data for comprehensive finality testing
}

func (h *testHeadTracker) PrefetchHead() types.HeadInfo {
Expand All @@ -151,13 +331,33 @@ func (h *testHeadTracker) ValidatedOptimistic() (types.OptimisticUpdate, bool) {
}, h.validated.Header != (types.Header{})
}

// TODO add test case for finality
// ValidatedFinality returns the most recent finality update if available.
//
// This method implements a two-tier approach:
// 1. Primary: If explicit finality data is set (h.finalized), return it directly
// 2. Fallback: For backward compatibility with existing tests, create a minimal
// finality update from the validated header
//
// The fallback ensures that existing tests continue to work while new tests
// can take advantage of the more comprehensive finality testing capabilities.
func (h *testHeadTracker) ValidatedFinality() (types.FinalityUpdate, bool) {
finalized := types.NewExecutionHeader(new(deneb.ExecutionPayloadHeader))
return types.FinalityUpdate{
Attested: types.HeaderWithExecProof{Header: h.validated.Header},
Finalized: types.HeaderWithExecProof{PayloadHeader: finalized},
Signature: h.validated.Signature,
SignatureSlot: h.validated.SignatureSlot,
}, h.validated.Header != (types.Header{})
// Primary path: Return explicit finality data if available
if h.finalized.Attested.Header != (types.Header{}) {
return h.finalized, true
}

// Fallback path: Create minimal finality update for backward compatibility
// This ensures existing tests continue to work without modification
if h.validated.Header != (types.Header{}) {
finalized := types.NewExecutionHeader(new(deneb.ExecutionPayloadHeader))
return types.FinalityUpdate{
Attested: types.HeaderWithExecProof{Header: h.validated.Header},
Finalized: types.HeaderWithExecProof{PayloadHeader: finalized},
Signature: h.validated.Signature,
SignatureSlot: h.validated.SignatureSlot,
}, true
}

// No finality data available
return types.FinalityUpdate{}, false
}