beacon/blsync: add comprehensive finality test coverage

This commit is contained in:
floor-licker 2025-07-12 19:46:35 -04:00
parent b992b105ef
commit 27dc374b4c

View file

@ -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
}