From 27dc374b4cd8b9247f8372299969e7dbfb09097d Mon Sep 17 00:00:00 2001 From: floor-licker Date: Sat, 12 Jul 2025 19:46:35 -0400 Subject: [PATCH] beacon/blsync: add comprehensive finality test coverage --- beacon/blsync/block_sync_test.go | 220 +++++++++++++++++++++++++++++-- 1 file changed, 210 insertions(+), 10 deletions(-) diff --git a/beacon/blsync/block_sync_test.go b/beacon/blsync/block_sync_test.go index e7c2c4d1631..3d1003621dc 100644 --- a/beacon/blsync/block_sync_test.go +++ b/beacon/blsync/block_sync_test.go @@ -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 { @@ -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 }