From 73be3eb3b9e24458ea641008c75be1399acf0e61 Mon Sep 17 00:00:00 2001 From: kewei Date: Tue, 25 Aug 2026 12:08:44 +0800 Subject: [PATCH 01/43] cl: implement Gloas devnet 8 --- cl/abstract/beacon_state.go | 2 +- .../mock_services/beacon_state_mock.go | 12 +- cl/beacon/handler/block_production.go | 45 +- cl/beacon/handler/block_production_test.go | 35 ++ cl/cltypes/partial_data_column.go | 26 +- cl/cltypes/partial_data_column_test.go | 11 +- cl/phase1/core/state/cache_accessors.go | 3 +- cl/phase1/core/state/upgrade.go | 22 +- .../forkchoice/fork_graph/fork_graph_disk.go | 232 +++++++- .../fork_graph/fork_graph_disk_fs.go | 91 ++- .../forkchoice/fork_graph/fork_graph_test.go | 485 ++++++++++++++++ cl/phase1/forkchoice/fork_graph/interface.go | 8 + .../participation_indicies_store.go | 15 +- cl/phase1/forkchoice/forkchoice.go | 153 +++++- cl/phase1/forkchoice/forkchoice_test.go | 57 +- cl/phase1/forkchoice/get_head.go | 24 +- cl/phase1/forkchoice/interface.go | 6 +- .../mock_services/forkchoice_mock.go | 23 +- cl/phase1/forkchoice/on_block.go | 9 +- .../on_block_fork_consistency_test.go | 17 + cl/phase1/forkchoice/on_execution_payload.go | 13 +- .../forkchoice/on_execution_payload_test.go | 2 +- .../payload_attestation_validation.go | 2 +- .../payload_attestation_validation_test.go | 17 + cl/phase1/forkchoice/payload_vote.go | 49 +- cl/phase1/forkchoice/payload_vote_test.go | 378 ++++++++++++- .../forkchoice/pending_el_payload_test.go | 15 + cl/phase1/forkchoice/timing.go | 37 +- cl/phase1/forkchoice/timing_test.go | 6 + .../services/execution_payload_bid_service.go | 520 ++++++++++-------- .../execution_payload_bid_service_test.go | 502 +++++++++++++++-- .../services/execution_payload_service.go | 7 +- .../execution_payload_service_test.go | 3 +- .../services/proposer_preferences_service.go | 133 +++-- .../proposer_preferences_service_test.go | 308 +++++++++-- cl/phase1/stages/chain_tip_sync.go | 34 +- cl/phase1/stages/forward_sync.go | 11 +- cl/pool/epbs_pool.go | 66 ++- cl/pool/operations_pool_test.go | 34 ++ cl/spectest/consensus_tests/appendix.go | 4 +- cl/spectest/consensus_tests/operations.go | 8 +- cl/transition/impl/eth2/operations.go | 52 +- cl/transition/machine/block.go | 10 +- cl/transition/machine/block_gloas_test.go | 4 +- cl/transition/machine/machine.go | 4 +- test-fixtures.json | 6 +- 46 files changed, 2867 insertions(+), 634 deletions(-) diff --git a/cl/abstract/beacon_state.go b/cl/abstract/beacon_state.go index 7588df22fad..2ee81b6d7ac 100644 --- a/cl/abstract/beacon_state.go +++ b/cl/abstract/beacon_state.go @@ -54,7 +54,7 @@ type BeaconStateExtension interface { BaseReward(index uint64) (uint64, error) SyncRewards() (proposerReward, participantReward uint64, err error) CommitteeCount(epoch uint64) uint64 - GetAttestationParticipationFlagIndicies(data *solid.AttestationData, inclusionDelay uint64, skipAssert bool) ([]uint8, error) + GetAttestationParticipationFlagIndicies(data *solid.AttestationData, inclusionDelay, parentSlot uint64, skipAssert bool) ([]uint8, error) GetBeaconCommitee(slot, committeeIndex uint64) ([]uint64, error) ComputeNextSyncCommittee() (*solid.SyncCommittee, error) GetAttestingIndicies(attestation *solid.Attestation, checkBitsLength bool) ([]uint64, error) diff --git a/cl/abstract/mock_services/beacon_state_mock.go b/cl/abstract/mock_services/beacon_state_mock.go index aa7e2bb47c0..5180a3984a2 100644 --- a/cl/abstract/mock_services/beacon_state_mock.go +++ b/cl/abstract/mock_services/beacon_state_mock.go @@ -1767,18 +1767,18 @@ func (c *MockBeaconStateGetActiveValidatorsIndicesCall) DoAndReturn(f func(uint6 } // GetAttestationParticipationFlagIndicies mocks base method. -func (m *MockBeaconState) GetAttestationParticipationFlagIndicies(data *solid.AttestationData, inclusionDelay uint64, skipAssert bool) ([]uint8, error) { +func (m *MockBeaconState) GetAttestationParticipationFlagIndicies(data *solid.AttestationData, inclusionDelay, parentSlot uint64, skipAssert bool) ([]uint8, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAttestationParticipationFlagIndicies", data, inclusionDelay, skipAssert) + ret := m.ctrl.Call(m, "GetAttestationParticipationFlagIndicies", data, inclusionDelay, parentSlot, skipAssert) ret0, _ := ret[0].([]uint8) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAttestationParticipationFlagIndicies indicates an expected call of GetAttestationParticipationFlagIndicies. -func (mr *MockBeaconStateMockRecorder) GetAttestationParticipationFlagIndicies(data, inclusionDelay, skipAssert any) *MockBeaconStateGetAttestationParticipationFlagIndiciesCall { +func (mr *MockBeaconStateMockRecorder) GetAttestationParticipationFlagIndicies(data, inclusionDelay, parentSlot, skipAssert any) *MockBeaconStateGetAttestationParticipationFlagIndiciesCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAttestationParticipationFlagIndicies", reflect.TypeOf((*MockBeaconState)(nil).GetAttestationParticipationFlagIndicies), data, inclusionDelay, skipAssert) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAttestationParticipationFlagIndicies", reflect.TypeOf((*MockBeaconState)(nil).GetAttestationParticipationFlagIndicies), data, inclusionDelay, parentSlot, skipAssert) return &MockBeaconStateGetAttestationParticipationFlagIndiciesCall{Call: call} } @@ -1794,13 +1794,13 @@ func (c *MockBeaconStateGetAttestationParticipationFlagIndiciesCall) Return(arg0 } // Do rewrite *gomock.Call.Do -func (c *MockBeaconStateGetAttestationParticipationFlagIndiciesCall) Do(f func(*solid.AttestationData, uint64, bool) ([]uint8, error)) *MockBeaconStateGetAttestationParticipationFlagIndiciesCall { +func (c *MockBeaconStateGetAttestationParticipationFlagIndiciesCall) Do(f func(*solid.AttestationData, uint64, uint64, bool) ([]uint8, error)) *MockBeaconStateGetAttestationParticipationFlagIndiciesCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockBeaconStateGetAttestationParticipationFlagIndiciesCall) DoAndReturn(f func(*solid.AttestationData, uint64, bool) ([]uint8, error)) *MockBeaconStateGetAttestationParticipationFlagIndiciesCall { +func (c *MockBeaconStateGetAttestationParticipationFlagIndiciesCall) DoAndReturn(f func(*solid.AttestationData, uint64, uint64, bool) ([]uint8, error)) *MockBeaconStateGetAttestationParticipationFlagIndiciesCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index ac78bf30fe3..05d40a6834f 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -1055,20 +1055,24 @@ func (a *ApiHandler) produceBeaconBody( // we use the cached payload_expected_withdrawals from state. var gloasWithdrawalsState *state.CachingBeaconState // nil means use baseState for withdrawals if stateVersion >= clparams.GloasVersion { + headNode, err := a.forkchoiceStore.GetHeadNode() + if err != nil { + return nil, 0, fmt.Errorf("produceBeaconBody: failed to snapshot fork choice head: %w", err) + } + if err := validateGloasHeadSnapshot(baseBlockRoot, headNode); err != nil { + return nil, 0, err + } parentBid := baseState.GetLatestExecutionPayloadBid() if parentBid != nil { - // Fork boundary: the initial bid created by UpgradeToGloas has - // ParentBlockHash == Hash32() (zero) because pre-GLOAS blocks have no - // parent bid. Pre-GLOAS blocks always had their payloads executed, so - // the EL head is parentBid.BlockHash (the last pre-GLOAS block hash). - isPreGloasParent := parentBid.ParentBlockHash == (common.Hash{}) && parentBid.Slot == 0 + isPreGloasParent := baseBlockSlot/a.beaconChainCfg.SlotsPerEpoch < a.beaconChainCfg.GloasForkEpoch + buildOnFull := !isPreGloasParent && + headNode.PayloadStatus == cltypes.PayloadStatusFull && + a.forkchoiceStore.HasEnvelope(baseBlockRoot) && + a.forkchoiceStore.ShouldBuildOnFull(headNode, targetSlot) + head = gloasProposalExecutionHead(baseBlockSlot, a.beaconChainCfg, parentBid, buildOnFull) switch { case isPreGloasParent: - head = parentBid.BlockHash - case a.forkchoiceStore.GetHeadPayloadStatus() == cltypes.PayloadStatusFull && - a.forkchoiceStore.HasEnvelope(baseBlockRoot) && - a.forkchoiceStore.ShouldBuildOnFull(forkchoice.ForkChoiceNode{Root: baseBlockRoot, PayloadStatus: cltypes.PayloadStatusFull}): - head = parentBid.BlockHash + case buildOnFull: // Copy state and apply parent execution payload to compute correct withdrawals stateCopy, err := baseState.Copy() if err != nil { @@ -1090,8 +1094,6 @@ func (a *ApiHandler) produceBeaconBody( // ProcessParentExecutionPayload can verify the root match // against the parent bid's ExecutionRequestsRoot. beaconBody.ParentExecutionRequests = envelope.Message.ExecutionRequests - default: - head = parentBid.ParentBlockHash } } else { head = baseState.GetLatestBlockHash() @@ -1422,6 +1424,20 @@ func (a *ApiHandler) produceBeaconBody( return beaconBody, executionValue, nil } +func validateGloasHeadSnapshot(baseBlockRoot common.Hash, headNode forkchoice.ForkChoiceNode) error { + if headNode.Root != baseBlockRoot { + return fmt.Errorf("produceBeaconBody: fork choice head changed from %x to %x", baseBlockRoot, headNode.Root) + } + return nil +} + +func gloasProposalExecutionHead(baseBlockSlot uint64, cfg *clparams.BeaconChainConfig, parentBid *cltypes.ExecutionPayloadBid, buildOnFull bool) common.Hash { + if baseBlockSlot/cfg.SlotsPerEpoch < cfg.GloasForkEpoch || buildOnFull { + return parentBid.BlockHash + } + return parentBid.ParentBlockHash +} + func (a *ApiHandler) getBlockOperations(s *state.CachingBeaconState, targetSlot uint64) ( *solid.ListSSZ[*cltypes.AttesterSlashing], *solid.ListSSZ[*cltypes.ProposerSlashing], @@ -2666,10 +2682,15 @@ func computeAttestationReward( currentEpoch := state.Epoch(s) stateSlot := s.Slot() beaconConfig := s.BeaconConfig() + var parentSlot uint64 + if s.Version() >= clparams.GloasVersion && s.GetLatestExecutionPayloadBid() != nil { + parentSlot = s.GetLatestExecutionPayloadBid().Slot + } participationFlagsIndicies, err := s.GetAttestationParticipationFlagIndicies( data, stateSlot-data.Slot, + parentSlot, false, ) if err != nil { diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index b5b0401f4b7..4990c7f4b89 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -42,6 +42,7 @@ import ( "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/phase1/core/state/lru" "github.com/erigontech/erigon/cl/phase1/execution_client" + "github.com/erigontech/erigon/cl/phase1/forkchoice" sync_pool_mock "github.com/erigontech/erigon/cl/validator/sync_contribution_pool/mock_services" "github.com/erigontech/erigon/cl/validator/validator_params" "github.com/erigontech/erigon/common" @@ -89,6 +90,36 @@ func TestBlockBuilderWindowGloas(t *testing.T) { require.Equal(t, slotStart.Add(2250*time.Millisecond), window.pollUntil) } +func TestGloasProposalExecutionHeadAtForkBoundary(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + cfg.SlotsPerEpoch = 32 + cfg.GloasForkEpoch = 3 + parentBid := &cltypes.ExecutionPayloadBid{ + ParentBlockHash: common.HexToHash("0xaaaa"), + BlockHash: common.HexToHash("0xbbbb"), + } + + require.Equal(t, parentBid.BlockHash, gloasProposalExecutionHead(95, &cfg, parentBid, false)) + require.Equal(t, parentBid.BlockHash, gloasProposalExecutionHead(90, &cfg, parentBid, false)) + require.Equal(t, parentBid.ParentBlockHash, gloasProposalExecutionHead(96, &cfg, parentBid, false)) +} + +func TestValidateGloasHeadSnapshotRejectsMismatchedRoot(t *testing.T) { + baseRoot := common.HexToHash("0xa1") + headNode := forkchoice.ForkChoiceNode{ + Root: common.HexToHash("0xb2"), + PayloadStatus: cltypes.PayloadStatusFull, + } + + err := validateGloasHeadSnapshot(baseRoot, headNode) + require.Error(t, err) + require.Contains(t, err.Error(), "fork choice head changed") + require.NoError(t, validateGloasHeadSnapshot(baseRoot, forkchoice.ForkChoiceNode{ + Root: baseRoot, + PayloadStatus: cltypes.PayloadStatusEmpty, + })) +} + func TestPublishBlindedBlocksRejectsGloas(t *testing.T) { _, _, _, _, _, h, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blinded_blocks", bytes.NewReader(nil)) @@ -644,6 +675,8 @@ func TestCaplinBlockProductionWithWithdrawalRequest(t *testing.T) { targetSlot := baseBlock.Slot + 1 baseBlockRoot, err := baseBlock.HashSSZ() require.NoError(t, err) + fcu.HeadVal = baseBlockRoot + fcu.HeadPayloadStatusVal = cltypes.PayloadStatusFull beaconBody, execValue, err := h.produceBeaconBody( ctx, 3, baseBlock.Slot, baseBlockRoot, postState, targetSlot, @@ -755,6 +788,8 @@ func TestCaplinBlockProductionGlamsterdamSlotNumber(t *testing.T) { targetSlot := baseBlock.Slot + 1 baseBlockRoot, err := baseBlock.HashSSZ() require.NoError(t, err) + fcu.HeadVal = baseBlockRoot + fcu.HeadPayloadStatusVal = cltypes.PayloadStatusFull // GLOAS deferred payload: the mock returns GetHeadPayloadStatus=FULL and ShouldBuildOnFull=true, // so block production expects an envelope on disk. Provide one with empty ExecutionRequests. diff --git a/cl/cltypes/partial_data_column.go b/cl/cltypes/partial_data_column.go index ce1bc35133c..4ab720464e0 100644 --- a/cl/cltypes/partial_data_column.go +++ b/cl/cltypes/partial_data_column.go @@ -235,14 +235,19 @@ func (s *PartialDataColumnSidecar) GetHeader() *PartialDataColumnHeader { type PartialDataColumnPartsMetadata struct { Available *solid.BitList `json:"available"` Requests *solid.BitList `json:"requests"` + version clparams.StateVersion } -func NewPartialDataColumnPartsMetadata() *PartialDataColumnPartsMetadata { - m := &PartialDataColumnPartsMetadata{} +func NewPartialDataColumnPartsMetadata(version clparams.StateVersion) *PartialDataColumnPartsMetadata { + m := &PartialDataColumnPartsMetadata{version: version} m.init() return m } +func (m *PartialDataColumnPartsMetadata) SetVersion(version clparams.StateVersion) { + m.version = version +} + func (m *PartialDataColumnPartsMetadata) init() { cfg := clparams.GetBeaconConfig() if m.Available == nil { @@ -259,6 +264,7 @@ func (m *PartialDataColumnPartsMetadata) EncodeSSZ(buf []byte) ([]byte, error) { } func (m *PartialDataColumnPartsMetadata) DecodeSSZ(buf []byte, version int) error { + m.version = clparams.StateVersion(version) m.init() return ssz2.UnmarshalSSZ(buf, version, m.Available, m.Requests) } @@ -270,11 +276,25 @@ func (m *PartialDataColumnPartsMetadata) EncodingSizeSSZ() int { func (m *PartialDataColumnPartsMetadata) HashSSZ() ([32]byte, error) { m.init() + if m.version >= clparams.GloasVersion { + availableRoot, err := m.Available.HashSSZProgressive() + if err != nil { + return [32]byte{}, err + } + requestsRoot, err := m.Requests.HashSSZProgressive() + if err != nil { + return [32]byte{}, err + } + return merkle_tree.HashTreeRoot(availableRoot[:], requestsRoot[:]) + } return merkle_tree.HashTreeRoot(m.Available, m.Requests) } func (m *PartialDataColumnPartsMetadata) Clone() clonable.Clonable { - return NewPartialDataColumnPartsMetadata() + if m == nil { + return NewPartialDataColumnPartsMetadata(clparams.Phase0Version) + } + return NewPartialDataColumnPartsMetadata(m.version) } func (m *PartialDataColumnPartsMetadata) Static() bool { diff --git a/cl/cltypes/partial_data_column_test.go b/cl/cltypes/partial_data_column_test.go index 2bab18f9787..a74ce288bfc 100644 --- a/cl/cltypes/partial_data_column_test.go +++ b/cl/cltypes/partial_data_column_test.go @@ -109,7 +109,7 @@ func TestPartialDataColumnPartsMetadata_Fulu(t *testing.T) { if _, err := os.Stat(testDir); os.IsNotExist(err) { t.Skip("spec test data not found") } - obj := cltypes.NewPartialDataColumnPartsMetadata() + obj := cltypes.NewPartialDataColumnPartsMetadata(clparams.FuluVersion) testSSZRoundTrip(t, testDir, clparams.FuluVersion, obj) } @@ -118,6 +118,13 @@ func TestPartialDataColumnPartsMetadata_Gloas(t *testing.T) { if _, err := os.Stat(testDir); os.IsNotExist(err) { t.Skip("spec test data not found") } - obj := cltypes.NewPartialDataColumnPartsMetadata() + obj := cltypes.NewPartialDataColumnPartsMetadata(clparams.GloasVersion) testSSZRoundTrip(t, testDir, clparams.GloasVersion, obj) } + +func TestPartialDataColumnPartsMetadataCloneNil(t *testing.T) { + var metadata *cltypes.PartialDataColumnPartsMetadata + clone := metadata.Clone() + require.NotNil(t, clone) + require.IsType(t, &cltypes.PartialDataColumnPartsMetadata{}, clone) +} diff --git a/cl/phase1/core/state/cache_accessors.go b/cl/phase1/core/state/cache_accessors.go index d90f6002c08..ae6d0ea313d 100644 --- a/cl/phase1/core/state/cache_accessors.go +++ b/cl/phase1/core/state/cache_accessors.go @@ -237,6 +237,7 @@ func (b *CachingBeaconState) CommitteeCount(epoch uint64) uint64 { func (b *CachingBeaconState) GetAttestationParticipationFlagIndicies( data *solid.AttestationData, inclusionDelay uint64, + parentSlot uint64, skipAssert bool, ) ([]uint8, error) { var justifiedCheckpoint solid.Checkpoint @@ -275,7 +276,7 @@ func (b *CachingBeaconState) GetAttestationParticipationFlagIndicies( } payloadMatch = true } else { - slotIndex := data.Slot % b.BeaconConfig().SlotsPerHistoricalRoot + slotIndex := parentSlot % b.BeaconConfig().SlotsPerHistoricalRoot aval := b.GetExecutionPayloadAvailability() payloadAvailable := aval.GetBitAt(int(slotIndex)) payloadMatch = (data.CommitteeIndex == 1) == payloadAvailable diff --git a/cl/phase1/core/state/upgrade.go b/cl/phase1/core/state/upgrade.go index bde3dd067bf..bce3d1a099b 100644 --- a/cl/phase1/core/state/upgrade.go +++ b/cl/phase1/core/state/upgrade.go @@ -47,7 +47,7 @@ func (b *CachingBeaconState) UpgradeToAltair() error { // Fill in previous epoch participation from the pre state's pending attestations if err := solid.RangeErr[*solid.PendingAttestation](b.PreviousEpochAttestations(), func(i1 int, pa *solid.PendingAttestation, i2 int) error { attestationData := pa.Data - flags, err := b.GetAttestationParticipationFlagIndicies(attestationData, pa.InclusionDelay, false) + flags, err := b.GetAttestationParticipationFlagIndicies(attestationData, pa.InclusionDelay, 0, false) if err != nil { return err } @@ -304,12 +304,10 @@ func (b *CachingBeaconState) UpgradeToGloas() error { forkData.CurrentVersion = utils.Uint32ToBytes4(uint32(cfg.GloasForkVersion)) b.SetFork(forkData) - // Get the latest block hash from the previous execution payload header - latestBlockHash := b.LatestExecutionPayloadHeader().BlockHash + latestPayloadHeader := b.LatestExecutionPayloadHeader() + latestBlockHeader := b.LatestBlockHeader() + latestBlockHash := latestPayloadHeader.BlockHash - // Replace latest_execution_payload_header with latest_execution_payload_bid - // The bid contains only the block_hash from the previous header - // Compute the execution_requests_root for an empty ExecutionRequests emptyRequests := cltypes.NewExecutionRequestsWithVersion(cfg, clparams.GloasVersion) emptyRequestsRoot, err := emptyRequests.HashSSZ() if err != nil { @@ -317,16 +315,16 @@ func (b *CachingBeaconState) UpgradeToGloas() error { } bid := &cltypes.ExecutionPayloadBid{ - ParentBlockHash: common.Hash{}, + ParentBlockHash: latestPayloadHeader.ParentHash, + ParentBlockRoot: latestBlockHeader.ParentRoot, BlockHash: latestBlockHash, - BuilderIndex: 0, - Slot: 0, + PrevRandao: latestPayloadHeader.PrevRandao, + GasLimit: latestPayloadHeader.GasLimit, + BuilderIndex: clparams.BuilderIndexSelfBuild, + Slot: latestBlockHeader.Slot, Value: 0, BlobKzgCommitments: *solid.NewStaticListSSZ[*cltypes.KZGCommitment](cltypes.MaxBlobsCommittmentsPerBlock, 48), ExecutionRequestsRoot: emptyRequestsRoot, - PrevRandao: common.Hash{}, - GasLimit: b.LatestExecutionPayloadHeader().GasLimit, - ParentBlockRoot: common.Hash{}, } b.SetLatestExecutionPayloadBid(bid) diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go b/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go index fa27fce030f..8541a13fc8b 100644 --- a/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go +++ b/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go @@ -40,12 +40,18 @@ import ( ) const dumpSlotFrequency = 4 +const pruneBatchSize = 256 type syncCommittees struct { currentSyncCommittee *solid.SyncCommittee nextSyncCommittee *solid.SyncCommittee } +type validatedChildren struct { + slots map[common.Hash]uint64 + maxSlot uint64 +} + var ErrStateNotFound = errors.New("state not found") type ChainSegmentInsertionResult uint @@ -89,11 +95,20 @@ func convertHashSliceToHashList(in [][32]byte) solid.HashVectorSSZ { // ForkGraph is our graph for ETH 2.0 consensus forkchoice. Each node is a (block root, changes) pair and // each edge is the path described as (prevBlockRoot, currBlockRoot). if we want to go forward we use blocks. type forkGraphDisk struct { + lifecycleMu sync.RWMutex + pruneMu sync.Mutex + pruneBatchHook func() + pruneChildrenHook func() + // Alternate beacon states - fs afero.Fs - blocks sync.Map // set of blocks (block root -> block) - headers sync.Map // set of headers - badBlocks sync.Map // blocks that are invalid and that leads to automatic fail of extension. + fs afero.Fs + blocks sync.Map // set of blocks (block root -> block) + headers sync.Map // set of headers + badBlocks sync.Map // blocks that are invalid and that leads to automatic fail of extension. + unavailablePayloads sync.Map + acceptedPayloads sync.Map + childrenMu sync.RWMutex + children map[common.Hash]*validatedChildren // current state data — dual-protected. AddChainSegment is the sole writer // and runs under the outer forkchoice f.mu, so reads taken under f.mu are @@ -184,6 +199,7 @@ func NewForkGraphDisk(anchorState *state.CachingBeaconState, syncedData synced_d anchorRoot: anchorRoot, rcfg: rcfg, syncedData: syncedData, + children: make(map[common.Hash]*validatedChildren), } f.lowestAvailableBlock.Store(anchorState.Slot()) f.headers.Store(common.Hash(anchorRoot), &anchorHeader) @@ -213,6 +229,9 @@ func (f *forkGraphDisk) isBlockRootTheCurrentState(blockRoot common.Hash) bool { // Add a new node and edge to the graph func (f *forkGraphDisk) AddChainSegment(signedBlock *cltypes.SignedBeaconBlock, fullValidation bool) (*state.CachingBeaconState, ChainSegmentInsertionResult, error) { + f.lifecycleMu.Lock() + defer f.lifecycleMu.Unlock() + block := signedBlock.Block blockRoot, err := block.HashSSZ() if err != nil { @@ -228,6 +247,10 @@ func (f *forkGraphDisk) AddChainSegment(signedBlock *cltypes.SignedBeaconBlock, f.badBlocks.Store(common.Hash(blockRoot), struct{}{}) return nil, BelowAnchor, nil } + if isBelowPrunedBoundary(block.Slot, f.lowestAvailableBlock.Load()) { + f.badBlocks.Store(common.Hash(blockRoot), struct{}{}) + return nil, BelowAnchor, nil + } isBlockRootTheCurrentState := f.isBlockRootTheCurrentState(blockRoot) var newState *state.CachingBeaconState @@ -376,6 +399,7 @@ func (f *forkGraphDisk) AddChainSegment(signedBlock *cltypes.SignedBeaconBlock, Root: block.StateRoot, BodyRoot: bodyRoot, }) + f.addValidatedChild(block.ParentRoot, common.Hash(blockRoot), block.Slot) // Lastly add checkpoints to caches as well. f.currentJustifiedCheckpoints.Store(common.Hash(blockRoot), newState.CurrentJustifiedCheckpoint()) @@ -384,12 +408,20 @@ func (f *forkGraphDisk) AddChainSegment(signedBlock *cltypes.SignedBeaconBlock, return newState, Success, nil } +func isBelowPrunedBoundary(slot, lowestAvailable uint64) bool { + return lowestAvailable > 0 && slot < lowestAvailable-1 +} + func (f *forkGraphDisk) GetHeader(blockRoot common.Hash) (*cltypes.BeaconBlockHeader, bool) { obj, has := f.headers.Load(blockRoot) if !has { return nil, false } - return obj.(*cltypes.BeaconBlockHeader), true + header := obj.(*cltypes.BeaconBlockHeader) + if isBelowPrunedBoundary(header.Slot, f.lowestAvailableBlock.Load()) { + return nil, false + } + return header, true } func (f *forkGraphDisk) GetBlock(blockRoot common.Hash) (*cltypes.SignedBeaconBlock, bool) { @@ -398,7 +430,62 @@ func (f *forkGraphDisk) GetBlock(blockRoot common.Hash) (*cltypes.SignedBeaconBl return nil, false } - return obj.(*cltypes.SignedBeaconBlock), true + block := obj.(*cltypes.SignedBeaconBlock) + if isBelowPrunedBoundary(block.Block.Slot, f.lowestAvailableBlock.Load()) { + return nil, false + } + return block, true +} + +func (f *forkGraphDisk) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool { + f.childrenMu.RLock() + defer f.childrenMu.RUnlock() + children := f.children[blockRoot] + return children != nil && !isBelowPrunedBoundary(children.maxSlot, f.lowestAvailableBlock.Load()) && children.maxSlot >= slot +} + +func (f *forkGraphDisk) addValidatedChild(parentRoot, childRoot common.Hash, slot uint64) { + f.childrenMu.Lock() + defer f.childrenMu.Unlock() + if f.children == nil { + f.children = make(map[common.Hash]*validatedChildren) + } + if f.children[parentRoot] == nil { + f.children[parentRoot] = &validatedChildren{slots: make(map[common.Hash]uint64)} + } + children := f.children[parentRoot] + children.slots[childRoot] = slot + if slot > children.maxSlot { + children.maxSlot = slot + } +} + +func (f *forkGraphDisk) removeValidatedChild(parentRoot, childRoot common.Hash) { + f.removeValidatedChildren(map[common.Hash][]common.Hash{parentRoot: {childRoot}}) +} + +func (f *forkGraphDisk) removeValidatedChildren(rootsByParent map[common.Hash][]common.Hash) { + f.childrenMu.Lock() + defer f.childrenMu.Unlock() + for parentRoot, roots := range rootsByParent { + children := f.children[parentRoot] + if children == nil { + continue + } + for _, root := range roots { + delete(children.slots, root) + } + if len(children.slots) == 0 { + delete(f.children, parentRoot) + continue + } + children.maxSlot = 0 + for _, slot := range children.slots { + if slot > children.maxSlot { + children.maxSlot = slot + } + } + } } func (f *forkGraphDisk) GetState(blockRoot common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) { @@ -559,13 +646,73 @@ func (f *forkGraphDisk) MarkHeaderAsInvalid(blockRoot common.Hash) { f.badBlocks.Store(blockRoot, struct{}{}) } +func (f *forkGraphDisk) IsBlockInvalid(blockRoot common.Hash) bool { + _, invalid := f.badBlocks.Load(blockRoot) + return invalid +} + +func (f *forkGraphDisk) MarkPayloadUnavailable(blockRoot common.Hash) { + f.unavailablePayloads.Store(blockRoot, struct{}{}) +} + +func (f *forkGraphDisk) MarkPayloadAvailable(blockRoot common.Hash) { + f.unavailablePayloads.Delete(blockRoot) +} + +func (f *forkGraphDisk) IsPayloadUnavailable(blockRoot common.Hash) bool { + _, unavailable := f.unavailablePayloads.Load(blockRoot) + return unavailable +} + +func (f *forkGraphDisk) MarkPayloadAccepted(blockRoot common.Hash, verified bool) { + f.acceptedPayloads.Store(blockRoot, verified) +} + +func (f *forkGraphDisk) ClearPayloadAccepted(blockRoot common.Hash) { + f.acceptedPayloads.Delete(blockRoot) +} + +func (f *forkGraphDisk) PayloadAccepted(blockRoot common.Hash) (bool, bool) { + verified, ok := f.acceptedPayloads.Load(blockRoot) + if !ok { + return false, false + } + return verified.(bool), true +} + +func (f *forkGraphDisk) retainedBlock(blockRoot common.Hash) bool { + header, ok := f.headers.Load(blockRoot) + if !ok { + return false + } + return !isBelowPrunedBoundary(header.(*cltypes.BeaconBlockHeader).Slot, f.lowestAvailableBlock.Load()) +} + +func (f *forkGraphDisk) IsBlockRetained(blockRoot common.Hash) bool { + f.lifecycleMu.RLock() + defer f.lifecycleMu.RUnlock() + return f.retainedBlock(blockRoot) +} + +func (f *forkGraphDisk) WithRetainedBlock(blockRoot common.Hash, fn func()) bool { + f.lifecycleMu.RLock() + defer f.lifecycleMu.RUnlock() + if !f.retainedBlock(blockRoot) { + return false + } + fn() + return true +} + func (f *forkGraphDisk) hasBeaconState(blockRoot common.Hash) bool { exists, err := afero.Exists(f.fs, getBeaconStateFilename(blockRoot)) return err == nil && exists } func (f *forkGraphDisk) Prune(pruneSlot uint64) (err error) { - oldRoots := make([]common.Hash, 0, f.beaconCfg.SlotsPerEpoch) + f.pruneMu.Lock() + defer f.pruneMu.Unlock() + highestStoredBeaconStateSlot := uint64(0) f.blocks.Range(func(key, value any) bool { hash := key.(common.Hash) @@ -573,21 +720,13 @@ func (f *forkGraphDisk) Prune(pruneSlot uint64) (err error) { if f.hasBeaconState(hash) && highestStoredBeaconStateSlot < signedBlock.Block.Slot { highestStoredBeaconStateSlot = signedBlock.Block.Slot } - if signedBlock.Block.Slot >= pruneSlot { - return true - } - - oldRoots = append(oldRoots, hash) return true }) if pruneSlot >= highestStoredBeaconStateSlot { return } - // prune the indicies for the epoch - f.currentIndicies.prune(pruneSlot / f.beaconCfg.SlotsPerEpoch) - f.previousIndicies.prune(pruneSlot / f.beaconCfg.SlotsPerEpoch) - + f.lifecycleMu.Lock() // Prune runs without the fork choice lock, so concurrent (or stale queued) // calls may arrive out of order: only ever raise the marker. for { @@ -596,19 +735,62 @@ func (f *forkGraphDisk) Prune(pruneSlot uint64) (err error) { break } } + f.lifecycleMu.Unlock() + + oldRoots := make([]common.Hash, 0, f.beaconCfg.SlotsPerEpoch) + validatedRootsByParent := make(map[common.Hash][]common.Hash) + f.blocks.Range(func(key, value any) bool { + if value.(*cltypes.SignedBeaconBlock).Block.Slot < pruneSlot { + root := key.(common.Hash) + oldRoots = append(oldRoots, root) + if header, ok := f.headers.Load(root); ok { + h := header.(*cltypes.BeaconBlockHeader) + validatedRootsByParent[h.ParentRoot] = append(validatedRootsByParent[h.ParentRoot], root) + } + } + return true + }) + currentIndexKeys := f.currentIndicies.keysThrough(pruneSlot / f.beaconCfg.SlotsPerEpoch) + previousIndexKeys := f.previousIndicies.keysThrough(pruneSlot / f.beaconCfg.SlotsPerEpoch) + for start := 0; start < len(oldRoots); start += pruneBatchSize { + end := min(start+pruneBatchSize, len(oldRoots)) + f.lifecycleMu.Lock() + for _, root := range oldRoots[start:end] { + f.blocks.Delete(root) + f.lightclientBootstraps.Delete(root) + f.currentJustifiedCheckpoints.Delete(root) + f.finalizedCheckpoints.Delete(root) + f.headers.Delete(root) + f.blockRewards.Delete(root) + f.envelopeExists.Delete(root) + f.unavailablePayloads.Delete(root) + f.acceptedPayloads.Delete(root) + f.badBlocks.Delete(root) + } + f.lifecycleMu.Unlock() + if f.pruneBatchHook != nil { + f.pruneBatchHook() + } + } + for start := 0; start < max(len(currentIndexKeys), len(previousIndexKeys)); start += pruneBatchSize { + f.lifecycleMu.Lock() + if start < len(currentIndexKeys) { + f.currentIndicies.deleteKeys(currentIndexKeys[start:min(start+pruneBatchSize, len(currentIndexKeys))]) + } + if start < len(previousIndexKeys) { + f.previousIndicies.deleteKeys(previousIndexKeys[start:min(start+pruneBatchSize, len(previousIndexKeys))]) + } + f.lifecycleMu.Unlock() + } + if f.pruneChildrenHook != nil { + f.pruneChildrenHook() + } + f.removeValidatedChildren(validatedRootsByParent) + for _, root := range oldRoots { - f.badBlocks.Delete(root) - f.blocks.Delete(root) - f.lightclientBootstraps.Delete(root) - f.currentJustifiedCheckpoints.Delete(root) - f.finalizedCheckpoints.Delete(root) - f.headers.Delete(root) - f.blockRewards.Delete(root) if err := f.fs.Remove(getBeaconStateFilename(root)); err != nil && !errors.Is(err, fs.ErrNotExist) { log.Debug("failed to remove pruned beacon state file", "root", root, "err", err) } - // [New in Gloas:EIP7732] Also remove envelope files - f.envelopeExists.Delete(root) if err := f.fs.Remove(getEnvelopeFilename(root)); err != nil && !errors.Is(err, fs.ErrNotExist) { log.Debug("failed to remove pruned envelope file", "root", root, "err", err) } diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go b/cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go index ceb59e869d0..c92c13f523a 100644 --- a/cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go +++ b/cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go @@ -18,6 +18,7 @@ package fork_graph import ( "encoding/binary" + "errors" "fmt" "io" "os" @@ -181,10 +182,20 @@ func (f *forkGraphDisk) DumpBeaconStateOnDisk(blockRoot common.Hash, bs *state.C // Uses an in-memory cache populated by DumpEnvelopeOnDisk to avoid repeated disk stats. // [New in Gloas:EIP7732] func (f *forkGraphDisk) HasEnvelope(blockRoot common.Hash) bool { + f.lifecycleMu.RLock() + defer f.lifecycleMu.RUnlock() + if !f.retainedBlock(blockRoot) { + return false + } // Fast path: check in-memory cache if _, ok := f.envelopeExists.Load(blockRoot); ok { return true } + f.stateDumpLock.Lock() + defer f.stateDumpLock.Unlock() + if _, ok := f.envelopeExists.Load(blockRoot); ok { + return true + } // Slow path: fall back to disk and populate cache on hit exists, err := afero.Exists(f.fs, getEnvelopeFilename(blockRoot)) if err == nil && exists { @@ -198,6 +209,11 @@ func (f *forkGraphDisk) HasEnvelope(blockRoot common.Hash) bool { // [New in Gloas:EIP7732] func (f *forkGraphDisk) ReadEnvelopeFromDisk(blockRoot common.Hash) (envelope *cltypes.SignedExecutionPayloadEnvelope, err error) { var file afero.File + f.lifecycleMu.RLock() + defer f.lifecycleMu.RUnlock() + if !f.retainedBlock(blockRoot) { + return nil, ErrStateNotFound + } f.stateDumpLock.Lock() defer f.stateDumpLock.Unlock() @@ -249,45 +265,61 @@ func (f *forkGraphDisk) ReadEnvelopeFromDisk(blockRoot common.Hash) (envelope *c // DumpEnvelopeOnDisk dumps an execution payload envelope to disk. // [New in Gloas:EIP7732] func (f *forkGraphDisk) DumpEnvelopeOnDisk(blockRoot common.Hash, envelope *cltypes.SignedExecutionPayloadEnvelope) (err error) { + f.lifecycleMu.RLock() + defer f.lifecycleMu.RUnlock() + header, ok := f.GetHeader(blockRoot) + if !ok || header == nil { + return fmt.Errorf("cannot dump envelope for unknown block root %x", blockRoot) + } + if isBelowPrunedBoundary(header.Slot, f.lowestAvailableBlock.Load()) { + return fmt.Errorf("cannot dump envelope for pruned block root %x at slot %d", blockRoot, header.Slot) + } f.stateDumpLock.Lock() defer f.stateDumpLock.Unlock() - // Populate in-memory cache on successful write - defer func() { - if err == nil { - f.envelopeExists.Store(blockRoot, struct{}{}) - } - }() - // Encode the envelope f.sszBuffer, err = envelope.EncodeSSZ(f.sszBuffer[:0]) if err != nil { return } - dumpedFile, err := f.fs.OpenFile(getEnvelopeFilename(blockRoot), os.O_TRUNC|os.O_CREATE|os.O_RDWR, 0o755) + filename := getEnvelopeFilename(blockRoot) + tmpFilename := filename + ".tmp" + dumpedFile, err := f.fs.OpenFile(tmpFilename, os.O_TRUNC|os.O_CREATE|os.O_RDWR, 0o755) if err != nil { return err } - defer dumpedFile.Close() + closed := false + defer func() { + if !closed { + err = errors.Join(err, dumpedFile.Close()) + } + if err != nil { + f.envelopeExists.Delete(blockRoot) + _ = f.fs.Remove(tmpFilename) + } + }() sw := snappypool.Writer(dumpedFile) - defer snappypool.PutWriter(sw) - - // Write the length - length := make([]byte, 8) - binary.BigEndian.PutUint64(length, uint64(len(f.sszBuffer))) - if _, err := sw.Write(length); err != nil { - log.Error("failed to write length", "err", err) - return err - } - // Write the envelope - if _, err := sw.Write(f.sszBuffer); err != nil { - log.Error("failed to write ssz buffer", "err", err) - return err - } - if err = sw.Flush(); err != nil { - log.Error("failed to flush snappy writer", "err", err) + err = func() error { + defer snappypool.PutWriter(sw) + length := make([]byte, 8) + binary.BigEndian.PutUint64(length, uint64(len(f.sszBuffer))) + if _, writeErr := sw.Write(length); writeErr != nil { + log.Error("failed to write length", "err", writeErr) + return writeErr + } + if _, writeErr := sw.Write(f.sszBuffer); writeErr != nil { + log.Error("failed to write ssz buffer", "err", writeErr) + return writeErr + } + if flushErr := sw.Flush(); flushErr != nil { + log.Error("failed to flush snappy writer", "err", flushErr) + return flushErr + } + return nil + }() + if err != nil { return err } @@ -295,6 +327,15 @@ func (f *forkGraphDisk) DumpEnvelopeOnDisk(blockRoot common.Hash, envelope *clty log.Error("failed to sync dumped file", "err", err) return } + if err = dumpedFile.Close(); err != nil { + closed = true + return + } + closed = true + if err = f.fs.Rename(tmpFilename, filename); err != nil { + return + } + f.envelopeExists.Store(blockRoot, struct{}{}) return } diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_test.go b/cl/phase1/forkchoice/fork_graph/fork_graph_test.go index ba0a7eafe75..bcb31b28770 100644 --- a/cl/phase1/forkchoice/fork_graph/fork_graph_test.go +++ b/cl/phase1/forkchoice/fork_graph/fork_graph_test.go @@ -18,7 +18,11 @@ package fork_graph import ( _ "embed" + "errors" + "os" + "sync" "testing" + "time" "github.com/erigontech/erigon/cl/beacon/beacon_router_configuration" "github.com/erigontech/erigon/cl/phase1/core/state" @@ -40,6 +44,84 @@ var block2 []byte //go:embed test_data/anchor_state.ssz_snappy var anchor []byte +type blockingRemoveFs struct { + afero.Fs + entered chan struct{} + release chan struct{} + once sync.Once +} + +type blockingPathRemoveFs struct { + afero.Fs + target string + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (fs *blockingPathRemoveFs) Remove(name string) error { + if name == fs.target { + fs.once.Do(func() { + close(fs.entered) + <-fs.release + }) + } + return fs.Fs.Remove(name) +} + +var errPartialEnvelopeWrite = errors.New("partial envelope write") + +type partialWriteFs struct { + afero.Fs + fail bool +} + +type blockingRenameFs struct { + afero.Fs + target string + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (fs *blockingRenameFs) Rename(oldname, newname string) error { + if newname == fs.target { + fs.once.Do(func() { + close(fs.entered) + <-fs.release + }) + } + return fs.Fs.Rename(oldname, newname) +} + +func (fs *partialWriteFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) { + file, err := fs.Fs.OpenFile(name, flag, perm) + if err != nil || !fs.fail { + return file, err + } + return partialWriteFile{File: file}, nil +} + +type partialWriteFile struct { + afero.File +} + +func (f partialWriteFile) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, errPartialEnvelopeWrite + } + n, _ := f.File.Write(p[:1]) + return n, errPartialEnvelopeWrite +} + +func (fs *blockingRemoveFs) Remove(name string) error { + fs.once.Do(func() { + close(fs.entered) + <-fs.release + }) + return fs.Fs.Remove(name) +} + func TestForkGraphInDisk(t *testing.T) { blockA, blockB, blockC := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion), cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion), @@ -87,6 +169,71 @@ func TestNewForkGraphDiskReturnsErrorOnDumpFailure(t *testing.T) { }) } +func TestDumpEnvelopeErrorDoesNotPublishPartialFile(t *testing.T) { + baseFs := afero.NewMemMapFs() + fs := &partialWriteFs{Fs: baseFs, fail: true} + cfg := clparams.MainnetBeaconConfig + f := &forkGraphDisk{fs: fs, beaconCfg: &cfg} + root := common.Hash{1} + f.headers.Store(root, &cltypes.BeaconBlockHeader{Slot: 1}) + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&cfg)} + + err := f.DumpEnvelopeOnDisk(root, envelope) + require.ErrorIs(t, err, errPartialEnvelopeWrite) + require.False(t, f.HasEnvelope(root)) + + fs.fail = false + require.NoError(t, f.DumpEnvelopeOnDisk(root, envelope)) + require.True(t, f.HasEnvelope(root)) + _, err = f.ReadEnvelopeFromDisk(root) + require.NoError(t, err) +} + +func TestDumpEnvelopeBeforePruneDoesNotSurvivePrune(t *testing.T) { + baseFs := afero.NewMemMapFs() + oldRoot := common.Hash{1} + newerRoot := common.Hash{2} + fs := &blockingRenameFs{ + Fs: baseFs, + target: getEnvelopeFilename(oldRoot), + entered: make(chan struct{}), + release: make(chan struct{}), + } + f := &forkGraphDisk{fs: fs, beaconCfg: &clparams.MainnetBeaconConfig, children: make(map[common.Hash]*validatedChildren)} + oldBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + oldBlock.Block.Slot = 64 + newerBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + newerBlock.Block.Slot = 128 + f.blocks.Store(oldRoot, oldBlock) + f.blocks.Store(newerRoot, newerBlock) + f.headers.Store(oldRoot, &cltypes.BeaconBlockHeader{Slot: oldBlock.Block.Slot}) + f.headers.Store(newerRoot, &cltypes.BeaconBlockHeader{Slot: newerBlock.Block.Slot}) + require.NoError(t, afero.WriteFile(baseFs, getBeaconStateFilename(oldRoot), []byte{1}, 0o644)) + require.NoError(t, afero.WriteFile(baseFs, getBeaconStateFilename(newerRoot), []byte{1}, 0o644)) + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)} + + dumpDone := make(chan error, 1) + go func() { dumpDone <- f.DumpEnvelopeOnDisk(oldRoot, envelope) }() + select { + case <-fs.entered: + case <-time.After(time.Second): + t.Fatal("envelope dump did not reach rename") + } + pruneDone := make(chan error, 1) + go func() { pruneDone <- f.Prune(100) }() + select { + case err := <-pruneDone: + close(fs.release) + require.NoError(t, err) + t.Fatal("prune crossed an active envelope publication") + case <-time.After(50 * time.Millisecond): + } + close(fs.release) + require.NoError(t, <-dumpDone) + require.NoError(t, <-pruneDone) + require.False(t, f.HasEnvelope(oldRoot)) +} + func TestNewForkGraphDiskCachesAnchorStateRoot(t *testing.T) { for _, tc := range []struct { name string @@ -142,10 +289,348 @@ func TestPruneKeepsLowestAvailableBlockMonotonic(t *testing.T) { } addBlockWithState(100, common.Hash{1}) addBlockWithState(200, common.Hash{2}) + f.MarkHeaderAsInvalid(common.Hash{1}) + require.True(t, f.IsBlockInvalid(common.Hash{1})) + f.MarkPayloadAccepted(common.Hash{1}, true) + verified, accepted := f.PayloadAccepted(common.Hash{1}) + require.True(t, accepted) + require.True(t, verified) require.NoError(t, f.Prune(150)) require.Equal(t, uint64(151), f.LowestAvailableSlot()) + require.False(t, f.IsBlockInvalid(common.Hash{1})) + _, accepted = f.PayloadAccepted(common.Hash{1}) + require.False(t, accepted) require.NoError(t, f.Prune(120)) require.Equal(t, uint64(151), f.LowestAvailableSlot()) } + +func TestOrphanEnvelopeIsNotRediscoveredAfterRootRemoval(t *testing.T) { + fs := afero.NewMemMapFs() + f := &forkGraphDisk{fs: fs, beaconCfg: &clparams.MainnetBeaconConfig} + root := common.Hash{1} + require.NoError(t, afero.WriteFile(fs, getEnvelopeFilename(root), []byte{1}, 0o644)) + + require.False(t, f.HasEnvelope(root)) + _, err := f.ReadEnvelopeFromDisk(root) + require.ErrorIs(t, err, ErrStateNotFound) + _, err = fs.Stat(getEnvelopeFilename(root)) + require.NoError(t, err) +} + +func TestHasBlockChildAtOrAfterUsesValidatedChildren(t *testing.T) { + f := &forkGraphDisk{} + parentRoot := common.Hash{1} + child := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + child.Block.ParentRoot = parentRoot + child.Block.Slot = 64 + f.blocks.Store(common.Hash{2}, child) + + require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 64)) + f.addValidatedChild(parentRoot, common.Hash{2}, 64) + require.True(t, f.HasBlockChildAtOrAfter(parentRoot, 64)) + require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 65)) + require.False(t, f.HasBlockChildAtOrAfter(common.Hash{3}, 64)) + f.removeValidatedChild(parentRoot, common.Hash{2}) + require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 64)) +} + +func TestRemoveValidatedChildrenBulkKeepsSameSlotSurvivor(t *testing.T) { + f := &forkGraphDisk{children: make(map[common.Hash]*validatedChildren)} + parentRoot := common.Hash{1} + removed := make([]common.Hash, 1024) + for i := range removed { + removed[i][0] = byte(i) + removed[i][1] = byte(i >> 8) + f.addValidatedChild(parentRoot, removed[i], 128) + } + survivor := common.Hash{0xff, 0xff} + f.addValidatedChild(parentRoot, survivor, 127) + + f.removeValidatedChildren(map[common.Hash][]common.Hash{parentRoot: removed}) + + require.True(t, f.HasBlockChildAtOrAfter(parentRoot, 127)) + require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 128)) + require.Equal(t, map[common.Hash]uint64{survivor: 127}, f.children[parentRoot].slots) +} + +func TestValidatedChildQueryProgressesDuringPruneLifecycle(t *testing.T) { + baseFs := afero.NewMemMapFs() + fs := &blockingRemoveFs{Fs: baseFs, entered: make(chan struct{}), release: make(chan struct{})} + f := &forkGraphDisk{fs: fs, beaconCfg: &clparams.MainnetBeaconConfig, children: make(map[common.Hash]*validatedChildren)} + parentRoot := common.Hash{1} + childRoot := common.Hash{2} + newerRoot := common.Hash{3} + child := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + child.Block.ParentRoot = parentRoot + child.Block.Slot = 64 + newer := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + newer.Block.Slot = 128 + f.blocks.Store(childRoot, child) + f.blocks.Store(newerRoot, newer) + f.headers.Store(childRoot, &cltypes.BeaconBlockHeader{ParentRoot: parentRoot, Slot: child.Block.Slot}) + f.headers.Store(newerRoot, &cltypes.BeaconBlockHeader{Slot: newer.Block.Slot}) + require.NoError(t, afero.WriteFile(baseFs, getBeaconStateFilename(childRoot), []byte{1}, 0o644)) + require.NoError(t, afero.WriteFile(baseFs, getBeaconStateFilename(newerRoot), []byte{1}, 0o644)) + f.addValidatedChild(parentRoot, childRoot, 64) + + pruneDone := make(chan error, 1) + go func() { pruneDone <- f.Prune(100) }() + select { + case <-fs.entered: + case <-time.After(time.Second): + t.Fatal("prune did not reach filesystem removal") + } + + result := make(chan bool, 1) + go func() { result <- f.HasBlockChildAtOrAfter(parentRoot, 64) }() + select { + case found := <-result: + require.False(t, found) + case <-time.After(time.Second): + close(fs.release) + t.Fatal("validated-child query blocked on the prune lifecycle") + } + + oldBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + oldBlock.Block.ParentRoot = parentRoot + oldBlock.Block.Slot = 63 + addDone := make(chan ChainSegmentInsertionResult, 1) + go func() { + _, result, _ := f.AddChainSegment(oldBlock, true) + addDone <- result + }() + select { + case result := <-addDone: + require.Equal(t, BelowAnchor, result) + case <-time.After(time.Second): + close(fs.release) + t.Fatal("below-boundary add blocked on filesystem cleanup") + } + aboveBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + aboveBlock.Block.ParentRoot = newerRoot + aboveBlock.Block.Slot = 129 + aboveAddDone := make(chan ChainSegmentInsertionResult, 1) + go func() { + _, result, _ := f.AddChainSegment(aboveBlock, true) + aboveAddDone <- result + }() + select { + case result := <-aboveAddDone: + require.NotEqual(t, BelowAnchor, result) + case <-time.After(time.Second): + close(fs.release) + t.Fatal("above-boundary add blocked on filesystem cleanup") + } + statusDone := make(chan bool, 1) + go func() { + statusDone <- f.WithRetainedBlock(newerRoot, func() { f.MarkPayloadAccepted(newerRoot, false) }) + }() + select { + case retained := <-statusDone: + require.True(t, retained) + case <-time.After(time.Second): + close(fs.release) + t.Fatal("retained status update blocked on filesystem cleanup") + } + hasDone := make(chan bool, 1) + go func() { hasDone <- f.HasEnvelope(newerRoot) }() + select { + case found := <-hasDone: + require.False(t, found) + case <-time.After(time.Second): + close(fs.release) + t.Fatal("retained envelope query blocked on filesystem cleanup") + } + readDone := make(chan error, 1) + go func() { _, err := f.ReadEnvelopeFromDisk(newerRoot); readDone <- err }() + select { + case err := <-readDone: + require.Error(t, err) + case <-time.After(time.Second): + close(fs.release) + t.Fatal("retained envelope read blocked on filesystem cleanup") + } + close(fs.release) + require.NoError(t, <-pruneDone) + + require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 64)) + oldRoot, err := oldBlock.Block.HashSSZ() + require.NoError(t, err) + _, found := f.GetHeader(oldRoot) + require.False(t, found) +} + +func TestPruneYieldsLifecycleBetweenBatches(t *testing.T) { + fs := afero.NewMemMapFs() + entered := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + cleanupCalls := 0 + f := &forkGraphDisk{ + fs: fs, + beaconCfg: &clparams.MainnetBeaconConfig, + children: make(map[common.Hash]*validatedChildren), + pruneBatchHook: func() { + once.Do(func() { close(entered); <-release }) + }, + pruneChildrenHook: func() { cleanupCalls++ }, + } + parentRoot := common.Hash{0xaa} + staleParentRoot := common.Hash{0xbb} + oldRoots := make([]common.Hash, pruneBatchSize+1) + for i := range oldRoots { + oldRoots[i][0] = byte(i) + oldRoots[i][1] = byte(i >> 8) + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + block.Block.Slot = uint64(i + 1) + f.blocks.Store(oldRoots[i], block) + f.headers.Store(oldRoots[i], &cltypes.BeaconBlockHeader{ParentRoot: parentRoot, Slot: block.Block.Slot}) + f.addValidatedChild(parentRoot, oldRoots[i], block.Block.Slot) + } + f.addValidatedChild(staleParentRoot, oldRoots[len(oldRoots)-1], uint64(len(oldRoots))) + newRoot := common.Hash{0xff, 0xff, 0xff} + newBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + newBlock.Block.Slot = 512 + f.blocks.Store(newRoot, newBlock) + f.headers.Store(newRoot, &cltypes.BeaconBlockHeader{Slot: newBlock.Block.Slot}) + f.addValidatedChild(parentRoot, newRoot, newBlock.Block.Slot) + require.NoError(t, afero.WriteFile(fs, getBeaconStateFilename(newRoot), []byte{1}, 0o644)) + + done := make(chan error, 1) + go func() { done <- f.Prune(300) }() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("prune did not yield between batches") + } + require.Equal(t, uint64(301), f.LowestAvailableSlot()) + _, found := f.GetBlock(oldRoots[len(oldRoots)-1]) + require.False(t, found) + require.False(t, f.HasBlockChildAtOrAfter(staleParentRoot, 1)) + progress := make(chan bool, 1) + go func() { progress <- f.WithRetainedBlock(newRoot, func() { f.MarkPayloadAccepted(newRoot, false) }) }() + select { + case retained := <-progress: + require.True(t, retained) + case <-time.After(time.Second): + close(release) + t.Fatal("retained operation blocked between prune batches") + } + hasDone := make(chan bool, 1) + go func() { hasDone <- f.HasEnvelope(newRoot) }() + select { + case found := <-hasDone: + require.False(t, found) + case <-time.After(time.Second): + close(release) + t.Fatal("envelope query blocked between prune batches") + } + readDone := make(chan error, 1) + go func() { _, err := f.ReadEnvelopeFromDisk(newRoot); readDone <- err }() + select { + case err := <-readDone: + require.Error(t, err) + case <-time.After(time.Second): + close(release) + t.Fatal("envelope read blocked between prune batches") + } + close(release) + require.NoError(t, <-done) + for _, root := range oldRoots { + _, found := f.blocks.Load(root) + require.False(t, found) + } + require.Equal(t, 1, cleanupCalls) + require.True(t, f.HasBlockChildAtOrAfter(parentRoot, newBlock.Block.Slot)) + require.False(t, f.HasBlockChildAtOrAfter(parentRoot, newBlock.Block.Slot+1)) + require.False(t, f.HasBlockChildAtOrAfter(staleParentRoot, 1)) +} + +func TestHasEnvelopeDoesNotRepopulateCacheDuringPrune(t *testing.T) { + baseFs := afero.NewMemMapFs() + oldRoot := common.Hash{1} + newerRoot := common.Hash{2} + fs := &blockingPathRemoveFs{ + Fs: baseFs, + target: getEnvelopeFilename(oldRoot), + entered: make(chan struct{}), + release: make(chan struct{}), + } + f := &forkGraphDisk{fs: fs, beaconCfg: &clparams.MainnetBeaconConfig, children: make(map[common.Hash]*validatedChildren)} + oldBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + oldBlock.Block.Slot = 64 + newerBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + newerBlock.Block.Slot = 128 + f.blocks.Store(oldRoot, oldBlock) + f.blocks.Store(newerRoot, newerBlock) + f.headers.Store(oldRoot, &cltypes.BeaconBlockHeader{Slot: oldBlock.Block.Slot}) + f.headers.Store(newerRoot, &cltypes.BeaconBlockHeader{Slot: newerBlock.Block.Slot}) + require.NoError(t, afero.WriteFile(baseFs, getBeaconStateFilename(oldRoot), []byte{1}, 0o644)) + require.NoError(t, afero.WriteFile(baseFs, getBeaconStateFilename(newerRoot), []byte{1}, 0o644)) + require.NoError(t, afero.WriteFile(baseFs, getEnvelopeFilename(oldRoot), []byte{1}, 0o644)) + f.MarkHeaderAsInvalid(oldRoot) + f.MarkPayloadUnavailable(oldRoot) + + pruneDone := make(chan error, 1) + go func() { pruneDone <- f.Prune(100) }() + select { + case <-fs.entered: + case <-time.After(time.Second): + t.Fatal("prune did not reach envelope removal") + } + require.False(t, f.IsBlockInvalid(oldRoot)) + require.False(t, f.IsPayloadUnavailable(oldRoot)) + + queryDone := make(chan bool, 1) + go func() { queryDone <- f.HasEnvelope(oldRoot) }() + lateDumpDone := make(chan error, 1) + lateEnvelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)} + go func() { lateDumpDone <- f.DumpEnvelopeOnDisk(oldRoot, lateEnvelope) }() + select { + case found := <-queryDone: + close(fs.release) + require.NoError(t, <-pruneDone) + require.False(t, found) + case <-time.After(50 * time.Millisecond): + close(fs.release) + require.NoError(t, <-pruneDone) + select { + case found := <-queryDone: + require.False(t, found) + case <-time.After(time.Second): + t.Fatal("envelope query did not progress after prune completed") + } + } + select { + case err := <-lateDumpDone: + require.Error(t, err) + case <-time.After(time.Second): + t.Fatal("late envelope dump did not progress after prune completed") + } + require.False(t, f.HasEnvelope(oldRoot)) + require.False(t, f.IsBlockInvalid(oldRoot)) + require.False(t, f.IsPayloadUnavailable(oldRoot)) +} + +func TestAddChainSegmentRejectsSlotBelowPrunedBoundary(t *testing.T) { + f := &forkGraphDisk{ + anchorSlot: 0, + children: make(map[common.Hash]*validatedChildren), + } + f.lowestAvailableBlock.Store(65) + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + block.Block.Slot = 63 + + _, status, err := f.AddChainSegment(block, true) + require.NoError(t, err) + require.Equal(t, BelowAnchor, status) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + _, headerFound := f.GetHeader(root) + require.False(t, headerFound) + require.False(t, f.HasBlockChildAtOrAfter(block.Block.ParentRoot, block.Block.Slot)) + require.False(t, isBelowPrunedBoundary(64, 65)) + require.False(t, isBelowPrunedBoundary(^uint64(0), ^uint64(0))) +} diff --git a/cl/phase1/forkchoice/fork_graph/interface.go b/cl/phase1/forkchoice/fork_graph/interface.go index 17c6ce89834..7dedacee35a 100644 --- a/cl/phase1/forkchoice/fork_graph/interface.go +++ b/cl/phase1/forkchoice/fork_graph/interface.go @@ -39,11 +39,19 @@ type ForkGraph interface { AddChainSegment(signedBlock *cltypes.SignedBeaconBlock, fullValidation bool) (*state.CachingBeaconState, ChainSegmentInsertionResult, error) GetHeader(blockRoot common.Hash) (*cltypes.BeaconBlockHeader, bool) GetBlock(blockRoot common.Hash) (*cltypes.SignedBeaconBlock, bool) + HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool GetState(blockRoot common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) GetCurrentJustifiedCheckpoint(blockRoot common.Hash) (solid.Checkpoint, bool) GetFinalizedCheckpoint(blockRoot common.Hash) (solid.Checkpoint, bool) GetSyncCommittees(period uint64) (*solid.SyncCommittee, *solid.SyncCommittee, bool) MarkHeaderAsInvalid(blockRoot common.Hash) + IsBlockInvalid(blockRoot common.Hash) bool + MarkPayloadUnavailable(blockRoot common.Hash) + MarkPayloadAvailable(blockRoot common.Hash) + IsPayloadUnavailable(blockRoot common.Hash) bool + MarkPayloadAccepted(blockRoot common.Hash, verified bool) + ClearPayloadAccepted(blockRoot common.Hash) + PayloadAccepted(blockRoot common.Hash) (verified bool, ok bool) AnchorSlot() uint64 AnchorRoot() common.Hash Prune(uint64) error diff --git a/cl/phase1/forkchoice/fork_graph/participation_indicies_store.go b/cl/phase1/forkchoice/fork_graph/participation_indicies_store.go index 4c407920bf0..86a98d5d603 100644 --- a/cl/phase1/forkchoice/fork_graph/participation_indicies_store.go +++ b/cl/phase1/forkchoice/fork_graph/participation_indicies_store.go @@ -29,12 +29,19 @@ func (p *participationIndiciesStore) add(epoch uint64, participations []byte) { p.s.Store(epoch, append(prevBitlist, participations...)) } -func (p *participationIndiciesStore) prune(epoch uint64) { - // iterate over the map and delete all keys less or equal than epoch - p.s.Range(func(key, value any) bool { +func (p *participationIndiciesStore) keysThrough(epoch uint64) []uint64 { + keys := []uint64{} + p.s.Range(func(key, _ any) bool { if key.(uint64) <= epoch { - p.s.Delete(key) + keys = append(keys, key.(uint64)) } return true }) + return keys +} + +func (p *participationIndiciesStore) deleteKeys(keys []uint64) { + for _, key := range keys { + p.s.Delete(key) + } } diff --git a/cl/phase1/forkchoice/forkchoice.go b/cl/phase1/forkchoice/forkchoice.go index b8e16d967e1..4612826bf82 100644 --- a/cl/phase1/forkchoice/forkchoice.go +++ b/cl/phase1/forkchoice/forkchoice.go @@ -458,10 +458,7 @@ func (f *ForkChoiceStore) GetRecentExecutionPayloadStatus(executionBlockHash com } func (f *ForkChoiceStore) GetRecentExecutionPayloadStatusByRoot(blockRoot common.Hash) (execution_client.PayloadStatus, bool) { - if f.payloadStatusByRoot == nil { - return execution_client.PayloadStatusNone, false - } - return f.payloadStatusByRoot.Get(blockRoot) + return f.payloadStatusAuthority(blockRoot) } // GetExecutionPayloadGasLimit returns the gas_limit of a recently validated execution payload. @@ -782,6 +779,10 @@ func (f *ForkChoiceStore) GetBlock(blockRoot common.Hash) (*cltypes.SignedBeacon return f.forkGraph.GetBlock(blockRoot) } +func (f *ForkChoiceStore) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool { + return f.forkGraph.HasBlockChildAtOrAfter(blockRoot, slot) +} + // HasEnvelope delegates to forkGraph.HasEnvelope. // [New in Gloas:EIP7732] func (f *ForkChoiceStore) HasEnvelope(blockRoot common.Hash) bool { @@ -792,6 +793,15 @@ func (f *ForkChoiceStore) HasEnvelope(blockRoot common.Hash) bool { // has been accepted by the execution layer. // [New in Gloas:EIP7732] func (f *ForkChoiceStore) IsPayloadVerified(blockRoot common.Hash) bool { + if f.forkGraph == nil || !f.forkGraph.HasEnvelope(blockRoot) { + return false + } + if f.forkGraph != nil { + if verified, accepted := f.forkGraph.PayloadAccepted(blockRoot); accepted { + return verified + } + return false + } if f.verifiedExecutionPayload == nil { return false } @@ -799,45 +809,126 @@ func (f *ForkChoiceStore) IsPayloadVerified(blockRoot common.Hash) bool { } func (f *ForkChoiceStore) MarkPayloadVerified(blockRoot common.Hash, executionBlockHash common.Hash) { + f.MarkPayloadStatus(blockRoot, executionBlockHash, execution_client.PayloadStatusValidated) +} + +func (f *ForkChoiceStore) MarkPayloadStatus(blockRoot common.Hash, executionBlockHash common.Hash, status execution_client.PayloadStatus) execution_client.PayloadStatus { f.mu.Lock() defer f.mu.Unlock() - f.markPayloadVerifiedLocked(blockRoot, executionBlockHash) + return f.markPayloadStatusLocked(blockRoot, executionBlockHash, status) } -func (f *ForkChoiceStore) markPayloadVerifiedLocked(blockRoot common.Hash, executionBlockHash common.Hash) { - if f.verifiedExecutionPayload == nil { - return - } - f.verifiedExecutionPayload.Add(blockRoot, struct{}{}) - if f.executionPayloadStatus != nil { - f.executionPayloadStatus.Add(executionBlockHash, execution_client.PayloadStatusValidated) - } - if f.payloadStatusByRoot != nil { - f.payloadStatusByRoot.Add(blockRoot, execution_client.PayloadStatusValidated) - } - f.headHash = common.Hash{} - f.headPayloadStatus = cltypes.PayloadStatusPending +type retainedBlockGuard interface { + WithRetainedBlock(common.Hash, func()) bool + IsBlockRetained(common.Hash) bool } -func (f *ForkChoiceStore) MarkPayloadInvalid(blockRoot common.Hash, executionBlockHash common.Hash) { +func (f *ForkChoiceStore) MarkPayloadStatusIfRetained(blockRoot common.Hash, executionBlockHash common.Hash, status execution_client.PayloadStatus) (execution_client.PayloadStatus, bool) { f.mu.Lock() defer f.mu.Unlock() - f.markPayloadInvalidLocked(blockRoot, executionBlockHash) + guard, ok := f.forkGraph.(retainedBlockGuard) + if !ok { + return f.markPayloadStatusLocked(blockRoot, executionBlockHash, status), true + } + effective := status + retained := guard.WithRetainedBlock(blockRoot, func() { + effective = f.markPayloadStatusRetainedLocked(blockRoot, executionBlockHash, status) + }) + return effective, retained +} + +func (f *ForkChoiceStore) MarkPayloadInvalid(blockRoot common.Hash, executionBlockHash common.Hash) { + f.MarkPayloadStatus(blockRoot, executionBlockHash, execution_client.PayloadStatusInvalidated) +} + +func (f *ForkChoiceStore) markPayloadStatusLocked(blockRoot common.Hash, executionBlockHash common.Hash, status execution_client.PayloadStatus) execution_client.PayloadStatus { + return f.markPayloadStatus(blockRoot, executionBlockHash, status, false) } -func (f *ForkChoiceStore) markPayloadInvalidLocked(blockRoot common.Hash, executionBlockHash common.Hash) { +func (f *ForkChoiceStore) markPayloadStatusRetainedLocked(blockRoot common.Hash, executionBlockHash common.Hash, status execution_client.PayloadStatus) execution_client.PayloadStatus { + return f.markPayloadStatus(blockRoot, executionBlockHash, status, true) +} + +func (f *ForkChoiceStore) markPayloadStatus(blockRoot common.Hash, executionBlockHash common.Hash, status execution_client.PayloadStatus, retained bool) execution_client.PayloadStatus { + current, known := f.payloadStatusAuthorityWithRetention(blockRoot, retained) + effective := status + if known { + switch current { + case execution_client.PayloadStatusInvalidated: + effective = execution_client.PayloadStatusInvalidated + case execution_client.PayloadStatusValidated: + if status != execution_client.PayloadStatusInvalidated { + effective = execution_client.PayloadStatusValidated + } + case execution_client.PayloadStatusNotValidated: + if status == execution_client.PayloadStatusNone { + effective = execution_client.PayloadStatusNotValidated + } + } + } if f.verifiedExecutionPayload != nil { - f.verifiedExecutionPayload.Remove(blockRoot) + if effective == execution_client.PayloadStatusValidated { + f.verifiedExecutionPayload.Add(blockRoot, struct{}{}) + } else { + f.verifiedExecutionPayload.Remove(blockRoot) + } } if f.executionPayloadStatus != nil { - f.executionPayloadStatus.Add(executionBlockHash, execution_client.PayloadStatusInvalidated) + f.executionPayloadStatus.Add(executionBlockHash, effective) + } + if f.payloadStatusByRoot != nil { + f.payloadStatusByRoot.Add(blockRoot, effective) + } + if f.forkGraph != nil { + switch effective { + case execution_client.PayloadStatusNone: + f.forkGraph.ClearPayloadAccepted(blockRoot) + f.forkGraph.MarkPayloadUnavailable(blockRoot) + case execution_client.PayloadStatusNotValidated: + f.forkGraph.MarkPayloadAvailable(blockRoot) + f.forkGraph.MarkPayloadAccepted(blockRoot, false) + case execution_client.PayloadStatusValidated: + f.forkGraph.MarkPayloadAvailable(blockRoot) + f.forkGraph.MarkPayloadAccepted(blockRoot, true) + case execution_client.PayloadStatusInvalidated: + f.forkGraph.MarkPayloadAvailable(blockRoot) + f.forkGraph.ClearPayloadAccepted(blockRoot) + f.forkGraph.MarkHeaderAsInvalid(blockRoot) + } + } + if !known || current != effective { + f.headHash = common.Hash{} + f.headPayloadStatus = cltypes.PayloadStatusPending + } + return effective +} + +func (f *ForkChoiceStore) payloadStatusAuthority(blockRoot common.Hash) (execution_client.PayloadStatus, bool) { + return f.payloadStatusAuthorityWithRetention(blockRoot, false) +} + +func (f *ForkChoiceStore) payloadStatusAuthorityWithRetention(blockRoot common.Hash, retained bool) (execution_client.PayloadStatus, bool) { + if f.forkGraph != nil { + if guard, ok := f.forkGraph.(retainedBlockGuard); ok && !retained && !guard.IsBlockRetained(blockRoot) { + return execution_client.PayloadStatusNone, false + } + if f.forkGraph.IsBlockInvalid(blockRoot) { + return execution_client.PayloadStatusInvalidated, true + } + if verified, accepted := f.forkGraph.PayloadAccepted(blockRoot); accepted { + if verified { + return execution_client.PayloadStatusValidated, true + } + return execution_client.PayloadStatusNotValidated, true + } + if f.forkGraph.IsPayloadUnavailable(blockRoot) { + return execution_client.PayloadStatusNone, true + } } if f.payloadStatusByRoot != nil { - f.payloadStatusByRoot.Add(blockRoot, execution_client.PayloadStatusInvalidated) + return f.payloadStatusByRoot.Get(blockRoot) } - f.forkGraph.MarkHeaderAsInvalid(blockRoot) - f.headHash = common.Hash{} - f.headPayloadStatus = cltypes.PayloadStatusPending + return execution_client.PayloadStatusNone, false } // ReadEnvelopeFromDisk delegates to forkGraph.ReadEnvelopeFromDisk. @@ -1094,6 +1185,14 @@ func pendingELPayloadRoot(p PendingELPayload) (common.Hash, bool) { // RequeuePendingELPayload queues a drained execution payload for another EL validation attempt. // [New in Gloas:EIP7732] func (f *ForkChoiceStore) RequeuePendingELPayload(p PendingELPayload) { + root, ok := pendingELPayloadRoot(p) + if !ok { + return + } + if guard, guarded := f.forkGraph.(retainedBlockGuard); guarded { + guard.WithRetainedBlock(root, func() { f.addPendingELPayload(p.Block, p.Envelope) }) + return + } f.addPendingELPayload(p.Block, p.Envelope) } diff --git a/cl/phase1/forkchoice/forkchoice_test.go b/cl/phase1/forkchoice/forkchoice_test.go index a9141295cba..64847e92d0e 100644 --- a/cl/phase1/forkchoice/forkchoice_test.go +++ b/cl/phase1/forkchoice/forkchoice_test.go @@ -37,6 +37,37 @@ import ( "github.com/erigontech/erigon/common" ) +type headerOnlyAnchorForkGraph struct { + fork_graph.ForkGraph + root common.Hash + slot uint64 +} + +func (g headerOnlyAnchorForkGraph) AnchorRoot() common.Hash { return g.root } +func (g headerOnlyAnchorForkGraph) AnchorSlot() uint64 { return g.slot } +func (g headerOnlyAnchorForkGraph) GetHeader(root common.Hash) (*cltypes.BeaconBlockHeader, bool) { + if root == g.root { + return &cltypes.BeaconBlockHeader{Slot: g.slot}, true + } + return nil, false +} + +func TestGetHeadNodeCachesHeaderOnlyAnchorFallback(t *testing.T) { + anchorRoot := common.HexToHash("0xa1") + store := &ForkChoiceStore{ + forkGraph: headerOnlyAnchorForkGraph{root: anchorRoot, slot: 42}, + beaconCfg: &clparams.MainnetBeaconConfig, + } + store.justifiedCheckpoint.Store(solid.Checkpoint{Root: common.HexToHash("0xb2")}) + + node, err := store.GetHeadNode() + require.NoError(t, err) + require.Equal(t, anchorRoot, node.Root) + require.Equal(t, cltypes.PayloadStatusPending, node.PayloadStatus) + require.Equal(t, anchorRoot, store.headHash) + require.Equal(t, uint64(42), store.headSlot) +} + func TestGetFinalizedExecutionHash(t *testing.T) { cache, err := lru.New[common.Hash, common.Hash](16) require.NoError(t, err) @@ -408,6 +439,15 @@ func (g *getFinalizedExecutionHashForkGraph) GetBlock(blockRoot common.Hash) (*c return block, block != nil } +func (g *getFinalizedExecutionHashForkGraph) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool { + for _, block := range g.blocks { + if block != nil && block.Block != nil && block.Block.ParentRoot == blockRoot && block.Block.Slot >= slot { + return true + } + } + return false +} + func (g *getFinalizedExecutionHashForkGraph) GetState(blockRoot common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) { g.getStateMu.Lock() g.getStateRoots = append(g.getStateRoots, blockRoot) @@ -451,6 +491,21 @@ func (g *getFinalizedExecutionHashForkGraph) MarkHeaderAsInvalid(common.Hash) { panic("not used") } +func (g *getFinalizedExecutionHashForkGraph) IsBlockInvalid(common.Hash) bool { + return false +} + +func (g *getFinalizedExecutionHashForkGraph) MarkPayloadUnavailable(common.Hash) {} +func (g *getFinalizedExecutionHashForkGraph) MarkPayloadAvailable(common.Hash) {} +func (g *getFinalizedExecutionHashForkGraph) IsPayloadUnavailable(common.Hash) bool { + return false +} +func (g *getFinalizedExecutionHashForkGraph) MarkPayloadAccepted(common.Hash, bool) {} +func (g *getFinalizedExecutionHashForkGraph) ClearPayloadAccepted(common.Hash) {} +func (g *getFinalizedExecutionHashForkGraph) PayloadAccepted(common.Hash) (bool, bool) { + return false, false +} + func (g *getFinalizedExecutionHashForkGraph) AnchorSlot() uint64 { return g.anchorSlot } @@ -519,5 +574,5 @@ func (g *getFinalizedExecutionHashForkGraph) ReadEnvelopeFromDisk(common.Hash) ( } func (g *getFinalizedExecutionHashForkGraph) HasEnvelope(common.Hash) bool { - panic("not used") + return false } diff --git a/cl/phase1/forkchoice/get_head.go b/cl/phase1/forkchoice/get_head.go index bafeacd4ee0..18421500f68 100644 --- a/cl/phase1/forkchoice/get_head.go +++ b/cl/phase1/forkchoice/get_head.go @@ -132,8 +132,11 @@ func (f *ForkChoiceStore) GetHead(auxilliaryState *state.CachingBeaconState) (co return f.headHash, f.headSlot, nil } headRoot, headSlot := f.forkGraph.AnchorRoot(), f.forkGraph.AnchorSlot() - f.publishSelectedHead(headRoot, headSlot) - return headRoot, headSlot, nil + f.headHash = headRoot + f.headSlot = headSlot + f.headPayloadStatus = cltypes.PayloadStatusPending + f.publishSelectedHead(f.headHash, f.headSlot) + return f.headHash, f.headSlot, nil } currentEpoch := f.computeEpochAtSlot(f.Slot()) @@ -151,6 +154,23 @@ func (f *ForkChoiceStore) GetHeadPayloadStatus() cltypes.PayloadStatus { return f.headPayloadStatus } +func (f *ForkChoiceStore) GetHeadNode() (ForkChoiceNode, error) { + for range 3 { + root, slot, err := f.GetHead(nil) + if err != nil { + return ForkChoiceNode{}, err + } + f.mu.RLock() + if f.headHash == root && f.headSlot == slot { + node := ForkChoiceNode{Root: root, PayloadStatus: f.headPayloadStatus} + f.mu.RUnlock() + return node, nil + } + f.mu.RUnlock() + } + return ForkChoiceNode{}, errors.New("fork choice head changed during snapshot") +} + // getHeadGloas returns the head using GLOAS fork choice rules. // [New in Gloas:EIP7732] func (f *ForkChoiceStore) getHeadGloas() (common.Hash, uint64, error) { diff --git a/cl/phase1/forkchoice/interface.go b/cl/phase1/forkchoice/interface.go index 9211048f700..3a90c38828c 100644 --- a/cl/phase1/forkchoice/interface.go +++ b/cl/phase1/forkchoice/interface.go @@ -46,6 +46,7 @@ type ForkChoiceStorageReader interface { // GetFinalizedExecutionHash returns the EL block hash for finalized/justified checkpoints. GetFinalizedExecutionHash(eth2Root common.Hash) common.Hash GetHead(auxilliaryState *state.CachingBeaconState) (common.Hash, uint64, error) + GetHeadNode() (ForkChoiceNode, error) HighestSeen() uint64 JustifiedCheckpoint() solid.Checkpoint JustifiedSlot() uint64 @@ -73,9 +74,10 @@ type ForkChoiceStorageReader interface { GetHeader(blockRoot common.Hash) (*cltypes.BeaconBlockHeader, bool) // [New in Gloas:EIP7732] GetBlock returns the full block for a given block root. GetBlock(blockRoot common.Hash) (*cltypes.SignedBeaconBlock, bool) + HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool // [New in Gloas:EIP7732] HasEnvelope checks if a signed execution payload envelope exists. HasEnvelope(blockRoot common.Hash) bool - // [New in Gloas:EIP7732] IsPayloadVerified checks whether the execution payload was accepted by the EL. + // IsPayloadVerified reports whether the EL has fully validated the payload. IsPayloadVerified(blockRoot common.Hash) bool // [New in Gloas:EIP7732] ReadEnvelopeFromDisk reads a signed execution payload envelope from disk. ReadEnvelopeFromDisk(blockRoot common.Hash) (*cltypes.SignedExecutionPayloadEnvelope, error) @@ -95,7 +97,7 @@ type ForkChoiceStorageReader interface { ShouldExtendPayload(root common.Hash) bool // [New in Gloas:EIP7732] ShouldBuildOnFull returns whether the proposer should build on // the full payload for the given head node. Used for proposer reorg of unavailable blocks. - ShouldBuildOnFull(head ForkChoiceNode) bool + ShouldBuildOnFull(head ForkChoiceNode, slot uint64) bool GetBalances(blockRoot common.Hash) (solid.Uint64ListSSZ, error) GetInactivitiesScores(blockRoot common.Hash) (solid.Uint64ListSSZ, error) diff --git a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go index caf98dc0f3e..c96a2ef6479 100644 --- a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go +++ b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go @@ -49,6 +49,8 @@ type ForkChoiceStorageMock struct { HeadVal common.Hash HeadSlotVal uint64 HeadPayloadStatusVal cltypes.PayloadStatus + GetHeadNodeFn func() (forkchoice.ForkChoiceNode, error) + GetStateAtBlockRootFn func(common.Hash, bool) (*state.CachingBeaconState, error) HighestSeenVal uint64 JustifiedCheckpointVal solid.Checkpoint JustifiedSlotVal uint64 @@ -290,6 +292,9 @@ func (f *ForkChoiceStorageMock) GetStateAtBlockRoot( blockRoot common.Hash, alwaysCopy bool, ) (*state.CachingBeaconState, error) { + if f.GetStateAtBlockRootFn != nil { + return f.GetStateAtBlockRootFn(blockRoot, alwaysCopy) + } st := f.StateAtBlockRootVal[blockRoot] if st == nil || !alwaysCopy { return st, nil @@ -402,6 +407,15 @@ func (f *ForkChoiceStorageMock) ForkNodes() []forkchoice.ForkNode { return f.WeightsMock } +func (f *ForkChoiceStorageMock) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool { + for _, header := range f.Headers { + if header != nil && header.ParentRoot == blockRoot && header.Slot >= slot { + return true + } + } + return false +} + func (f *ForkChoiceStorageMock) Synced() bool { return true } @@ -462,11 +476,18 @@ func (f *ForkChoiceStorageMock) GetHeadPayloadStatus() cltypes.PayloadStatus { return f.HeadPayloadStatusVal } +func (f *ForkChoiceStorageMock) GetHeadNode() (forkchoice.ForkChoiceNode, error) { + if f.GetHeadNodeFn != nil { + return f.GetHeadNodeFn() + } + return forkchoice.ForkChoiceNode{Root: f.HeadVal, PayloadStatus: f.HeadPayloadStatusVal}, nil +} + func (f *ForkChoiceStorageMock) ShouldExtendPayload(root common.Hash) bool { return f.ShouldExtendPayloadVal } -func (f *ForkChoiceStorageMock) ShouldBuildOnFull(head forkchoice.ForkChoiceNode) bool { +func (f *ForkChoiceStorageMock) ShouldBuildOnFull(head forkchoice.ForkChoiceNode, slot uint64) bool { return true } diff --git a/cl/phase1/forkchoice/on_block.go b/cl/phase1/forkchoice/on_block.go index b61c3a1a307..11603fa90da 100644 --- a/cl/phase1/forkchoice/on_block.go +++ b/cl/phase1/forkchoice/on_block.go @@ -95,13 +95,18 @@ func (f *ForkChoiceStore) OnBlock(ctx context.Context, block *cltypes.SignedBeac f.mu.Unlock() } }() - f.headHash = common.Hash{} - f.headPayloadStatus = cltypes.PayloadStatusPending start := time.Now() blockRoot, err := block.Block.HashSSZ() if err != nil { return err } + if block.Version() >= clparams.GloasVersion { + if _, ok := f.forkGraph.GetHeader(blockRoot); ok { + return nil + } + } + f.headHash = common.Hash{} + f.headPayloadStatus = cltypes.PayloadStatusPending // Use the store's current slot (set via OnTick) to validate the block is not from the future. // The spec says: assert get_current_slot(store) >= block.slot if f.Slot() < block.Block.Slot { diff --git a/cl/phase1/forkchoice/on_block_fork_consistency_test.go b/cl/phase1/forkchoice/on_block_fork_consistency_test.go index 7a22f7e23e4..77ae720ab64 100644 --- a/cl/phase1/forkchoice/on_block_fork_consistency_test.go +++ b/cl/phase1/forkchoice/on_block_fork_consistency_test.go @@ -25,6 +25,7 @@ import ( "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/utils" + "github.com/erigontech/erigon/common" ) // A response's decoded schema comes from the peer-chosen fork digest, so it is @@ -55,3 +56,19 @@ func TestOnBlockRejectsForkSchemaSlotMismatch(t *testing.T) { err := store.OnBlock(context.Background(), mismatched, false, true, true) require.ErrorIs(t, err, ErrForkSchemaSlotMismatch) } + +func TestOnBlockDoesNotShortCircuitKnownPreGloasBlock(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + block := cltypes.NewSignedBeaconBlock(&cfg, clparams.DenebVersion) + block.Block.Slot = 1 + root, err := block.Block.HashSSZ() + require.NoError(t, err) + graph := &getFinalizedExecutionHashForkGraph{ + headers: map[common.Hash]*cltypes.BeaconBlockHeader{root: {Slot: block.Block.Slot}}, + } + store := &ForkChoiceStore{beaconCfg: &cfg, forkGraph: graph} + + err = store.OnBlock(context.Background(), block, true, false, false) + + require.ErrorContains(t, err, "block is too early") +} diff --git a/cl/phase1/forkchoice/on_execution_payload.go b/cl/phase1/forkchoice/on_execution_payload.go index c5ac7d71f90..d85d1abe548 100644 --- a/cl/phase1/forkchoice/on_execution_payload.go +++ b/cl/phase1/forkchoice/on_execution_payload.go @@ -341,7 +341,16 @@ func (f *ForkChoiceStore) applyPayloadValidationResultLocked( // Track payload status and gas limit by execution block hash for parent payload validation executionBlockHash := envelope.Payload.BlockHash - f.executionPayloadStatus.Add(executionBlockHash, payloadStatus) + if guard, ok := f.forkGraph.(retainedBlockGuard); ok { + retained := guard.WithRetainedBlock(beaconBlockRoot, func() { + payloadStatus = f.markPayloadStatusRetainedLocked(beaconBlockRoot, executionBlockHash, payloadStatus) + }) + if !retained { + return fmt.Errorf("%w: block disappeared during payload validation for beacon_block_root %v", ErrIgnore, beaconBlockRoot) + } + } else { + payloadStatus = f.markPayloadStatusLocked(beaconBlockRoot, executionBlockHash, payloadStatus) + } f.executionPayloadGasLimit.Add(executionBlockHash, envelope.Payload.GasLimit) switch payloadStatus { @@ -364,11 +373,9 @@ func (f *ForkChoiceStore) applyPayloadValidationResultLocked( } case execution_client.PayloadStatusInvalidated: log.Warn("validatePayloadWithEL: payload is invalid", "beaconBlockRoot", beaconBlockRoot, "err", validationErr) - f.markPayloadInvalidLocked(beaconBlockRoot, executionBlockHash) return fmt.Errorf("%w: execution payload is invalid", errInvalidExecutionPayloadEnvelope) case execution_client.PayloadStatusValidated: log.Trace("validatePayloadWithEL: payload is validated", "beaconBlockRoot", beaconBlockRoot) - f.markPayloadVerifiedLocked(beaconBlockRoot, executionBlockHash) } if validationErr != nil { diff --git a/cl/phase1/forkchoice/on_execution_payload_test.go b/cl/phase1/forkchoice/on_execution_payload_test.go index 6ace68fd93c..e1bf7c5f805 100644 --- a/cl/phase1/forkchoice/on_execution_payload_test.go +++ b/cl/phase1/forkchoice/on_execution_payload_test.go @@ -1313,7 +1313,7 @@ func TestValidatePayloadWithELDoesNotRelockForkChoiceMu(t *testing.T) { { name: "validated", status: execution_client.PayloadStatusValidated, - wantVerify: true, + wantVerify: false, }, { name: "invalidated", diff --git a/cl/phase1/forkchoice/payload_attestation_validation.go b/cl/phase1/forkchoice/payload_attestation_validation.go index 06c04ae8d0f..ff38dff74ce 100644 --- a/cl/phase1/forkchoice/payload_attestation_validation.go +++ b/cl/phase1/forkchoice/payload_attestation_validation.go @@ -176,7 +176,7 @@ func (f *ForkChoiceStore) payloadAttestationValidationContext( return f.payloadAttestationContexts.get(ctx, blockRoot, func() (*payloadAttestationValidationContext, error) { blockState, err := f.GetStateAtBlockRoot(blockRoot, true) if err != nil { - return nil, err + return nil, fmt.Errorf("%w: block state unavailable for root %v: %w", ErrIgnore, blockRoot, err) } if blockState == nil { return nil, fmt.Errorf("%w: block state not found for root %v", ErrIgnore, blockRoot) diff --git a/cl/phase1/forkchoice/payload_attestation_validation_test.go b/cl/phase1/forkchoice/payload_attestation_validation_test.go index e87a37b6eaf..000c0a8191b 100644 --- a/cl/phase1/forkchoice/payload_attestation_validation_test.go +++ b/cl/phase1/forkchoice/payload_attestation_validation_test.go @@ -29,6 +29,7 @@ import ( "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/fork" + "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/utils/bls" "github.com/erigontech/erigon/common" ) @@ -68,6 +69,22 @@ func TestOnPayloadAttestationMessageRejectsNil(t *testing.T) { require.Error(t, f.OnPayloadAttestationMessage(context.Background(), &cltypes.PayloadAttestationMessage{}, false)) } +func TestOnPayloadAttestationMessageIgnoresUnavailableKnownBlockState(t *testing.T) { + root := common.HexToHash("0x1234") + contexts, err := newPayloadAttestationValidationContexts() + require.NoError(t, err) + graph := &getFinalizedExecutionHashForkGraph{ + headers: map[common.Hash]*cltypes.BeaconBlockHeader{root: {Slot: 100}}, + states: map[common.Hash]*state.CachingBeaconState{}, + } + f := &ForkChoiceStore{forkGraph: graph, payloadAttestationContexts: contexts} + msg := &cltypes.PayloadAttestationMessage{Data: &cltypes.PayloadAttestationData{Slot: 100, BeaconBlockRoot: root}} + + err = f.OnPayloadAttestationMessage(context.Background(), msg, true) + + require.ErrorIs(t, err, ErrIgnore) +} + func TestApplyValidatedPayloadAttestationAcceptsOnlyFirstGossipVote(t *testing.T) { f := &ForkChoiceStore{} root := common.HexToHash("0x1234") diff --git a/cl/phase1/forkchoice/payload_vote.go b/cl/phase1/forkchoice/payload_vote.go index 893549c2c5e..0e5784d05d6 100644 --- a/cl/phase1/forkchoice/payload_vote.go +++ b/cl/phase1/forkchoice/payload_vote.go @@ -132,10 +132,8 @@ func (f *ForkChoiceStore) payloadTimeliness(root common.Hash, timely bool) bool return false } - // If the payload has not been accepted by the execution layer, the payload - // is not considered available regardless of the PTC vote. - if !f.IsPayloadVerified(root) { - return false + if !f.isPayloadAvailable(root) { + return !timely } votes := voteRaw.([clparams.PtcSize]int8) target := boolToVote(timely) @@ -157,10 +155,8 @@ func (f *ForkChoiceStore) payloadDataAvailability(root common.Hash, available bo return false } - // If the payload has not been accepted by the execution layer, the blob data - // is not considered available regardless of the PTC vote. - if !f.IsPayloadVerified(root) { - return false + if !f.isPayloadAvailable(root) { + return !available } votes := voteRaw.([clparams.PtcSize]int8) target := boolToVote(available) @@ -289,7 +285,7 @@ func (f *ForkChoiceStore) isPreviousSlotPayloadDecision(node ForkChoiceNode) boo // Used by prepare_execution_payload to decide FULL vs EMPTY path. // [New in Gloas:EIP7732] func (f *ForkChoiceStore) ShouldExtendPayload(root common.Hash) bool { - if !f.IsPayloadVerified(root) { + if !f.isPayloadAvailable(root) { return false } @@ -325,16 +321,20 @@ func (f *ForkChoiceStore) ShouldExtendPayload(root common.Hash) bool { // for the given head node. Returns false for EMPTY heads. For FULL heads, returns // true unless the PTC voted the payload as late or blob data as unavailable. // [New in Gloas:EIP7732] -func (f *ForkChoiceStore) ShouldBuildOnFull(head ForkChoiceNode) bool { +func (f *ForkChoiceStore) ShouldBuildOnFull(head ForkChoiceNode, slot uint64) bool { + header, has := f.forkGraph.GetHeader(head.Root) + if !has || header == nil { + return false + } + if header.Slot+1 != slot { + return head.PayloadStatus == cltypes.PayloadStatusFull + } if head.PayloadStatus == cltypes.PayloadStatusEmpty { return false } if head.PayloadStatus == cltypes.PayloadStatusPending { return false } - if !f.isPreviousSlotPayloadDecision(head) { - return true - } if f.payloadDataAvailability(head.Root, false) { return false } @@ -371,7 +371,7 @@ func (f *ForkChoiceStore) getNodeChildren(node ForkChoiceNode, blocks map[common children := []ForkChoiceNode{ {Root: node.Root, PayloadStatus: cltypes.PayloadStatusEmpty}, } - if f.IsPayloadVerified(node.Root) { + if f.isPayloadAvailable(node.Root) { children = append(children, ForkChoiceNode{ Root: node.Root, PayloadStatus: cltypes.PayloadStatusFull, }) @@ -400,6 +400,23 @@ func (f *ForkChoiceStore) getNodeChildren(node ForkChoiceNode, blocks map[common return result } +func (f *ForkChoiceStore) isPayloadAvailable(root common.Hash) bool { + if !f.HasEnvelope(root) { + return false + } + if f.forkGraph.IsBlockInvalid(root) { + return false + } + if f.forkGraph.IsPayloadUnavailable(root) { + return false + } + status, ok := f.GetRecentExecutionPayloadStatusByRoot(root) + if !ok { + return f.HasEnvelope(root) + } + return status == execution_client.PayloadStatusNotValidated || status == execution_client.PayloadStatusValidated +} + // validateParentPayloadPath validates that the block builds on the correct parent payload path. // If parent is FULL, the parent must have an execution payload state. // If parent is EMPTY, the block's parent_block_hash must match the parent's parent_block_hash. @@ -420,12 +437,12 @@ func (f *ForkChoiceStore) validateParentPayloadPath(block *cltypes.BeaconBlock) } if f.isParentNodeFull(block) { - // Parent is FULL - verify execution payload envelope exists on disk. + // Parent is FULL - verify the execution payload is locally available. // Return ErrParentEnvelopePending (not a hard error) when the envelope is // missing. During forward sync the envelope may not yet be persisted (it // arrives in the same batch or in a later batch), so a hard error would // permanently reject the block and ban the peer. - if !f.forkGraph.HasEnvelope(block.ParentRoot) { + if !f.isPayloadAvailable(block.ParentRoot) { return ErrParentEnvelopePending } } else { diff --git a/cl/phase1/forkchoice/payload_vote_test.go b/cl/phase1/forkchoice/payload_vote_test.go index 3c27f881c19..b0d0ef7f3fc 100644 --- a/cl/phase1/forkchoice/payload_vote_test.go +++ b/cl/phase1/forkchoice/payload_vote_test.go @@ -1,6 +1,8 @@ package forkchoice import ( + "errors" + "fmt" "testing" lru "github.com/hashicorp/golang-lru/v2" @@ -26,16 +28,54 @@ func (g ptcVoteForkGraph) HasEnvelope(root common.Hash) bool { return g.envelopes[root] } +func (g ptcVoteForkGraph) IsBlockInvalid(common.Hash) bool { + return false +} + +func (g ptcVoteForkGraph) MarkPayloadUnavailable(common.Hash) {} +func (g ptcVoteForkGraph) MarkPayloadAvailable(common.Hash) {} +func (g ptcVoteForkGraph) IsPayloadUnavailable(common.Hash) bool { + return false +} +func (g ptcVoteForkGraph) MarkPayloadAccepted(common.Hash, bool) {} +func (g ptcVoteForkGraph) ClearPayloadAccepted(common.Hash) {} +func (g ptcVoteForkGraph) PayloadAccepted(common.Hash) (bool, bool) { + return false, false +} + func (g ptcVoteForkGraph) GetBlock(root common.Hash) (*cltypes.SignedBeaconBlock, bool) { block, ok := g.blocks[root] return block, ok } +func (g ptcVoteForkGraph) GetHeader(root common.Hash) (*cltypes.BeaconBlockHeader, bool) { + block, ok := g.blocks[root] + if !ok || block == nil || block.Block == nil { + return nil, false + } + return &cltypes.BeaconBlockHeader{Slot: block.Block.Slot, ParentRoot: block.Block.ParentRoot}, true +} + type payloadVoteForkGraph struct { fork_graph.ForkGraph - hasEnvelope bool - dumpedEnvelope *common.Hash - invalidatedHeader *common.Hash + hasEnvelope bool + dumpedEnvelope *common.Hash + invalidatedHeader *common.Hash + unavailablePayload *common.Hash + acceptedPayloads map[common.Hash]bool + retained *bool +} + +func (g payloadVoteForkGraph) IsBlockRetained(common.Hash) bool { + return g.retained == nil || *g.retained +} + +func (g payloadVoteForkGraph) WithRetainedBlock(_ common.Hash, fn func()) bool { + if !g.IsBlockRetained(common.Hash{}) { + return false + } + fn() + return true } func (g payloadVoteForkGraph) HasEnvelope(common.Hash) bool { @@ -59,6 +99,41 @@ func (g payloadVoteForkGraph) MarkHeaderAsInvalid(blockRoot common.Hash) { } } +func (g payloadVoteForkGraph) IsBlockInvalid(blockRoot common.Hash) bool { + return g.invalidatedHeader != nil && *g.invalidatedHeader == blockRoot +} + +func (g payloadVoteForkGraph) MarkPayloadUnavailable(blockRoot common.Hash) { + if g.unavailablePayload != nil { + *g.unavailablePayload = blockRoot + } +} + +func (g payloadVoteForkGraph) MarkPayloadAvailable(blockRoot common.Hash) { + if g.unavailablePayload != nil && *g.unavailablePayload == blockRoot { + *g.unavailablePayload = common.Hash{} + } +} + +func (g payloadVoteForkGraph) IsPayloadUnavailable(blockRoot common.Hash) bool { + return g.unavailablePayload != nil && *g.unavailablePayload == blockRoot +} + +func (g payloadVoteForkGraph) MarkPayloadAccepted(blockRoot common.Hash, verified bool) { + if g.acceptedPayloads != nil { + g.acceptedPayloads[blockRoot] = verified + } +} + +func (g payloadVoteForkGraph) ClearPayloadAccepted(blockRoot common.Hash) { + delete(g.acceptedPayloads, blockRoot) +} + +func (g payloadVoteForkGraph) PayloadAccepted(blockRoot common.Hash) (bool, bool) { + verified, ok := g.acceptedPayloads[blockRoot] + return verified, ok +} + func TestGetPTCFromWindow(t *testing.T) { cfg := &clparams.MainnetBeaconConfig s := state2.New(cfg) @@ -228,10 +303,10 @@ func TestPtcShouldBuildOnFullNoVotesCast(t *testing.T) { f := newPtcVoteTestStore(root) head := ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusFull} - require.True(t, f.ShouldBuildOnFull(head)) + require.True(t, f.ShouldBuildOnFull(head, f.Slot())) f.payloadDataAvailabilityVote.Store(root, ptcVotes(0, 0)) - require.True(t, f.ShouldBuildOnFull(head)) + require.True(t, f.ShouldBuildOnFull(head, f.Slot())) } func TestPtcShouldBuildOnFullWithUnavailableMajority(t *testing.T) { @@ -242,11 +317,11 @@ func TestPtcShouldBuildOnFullWithUnavailableMajority(t *testing.T) { require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{ Root: root, PayloadStatus: cltypes.PayloadStatusFull, - })) + }, f.Slot())) require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{ Root: root, PayloadStatus: cltypes.PayloadStatusEmpty, - })) + }, f.Slot())) } func TestPtcShouldBuildOnFullWithLatePayloadMajority(t *testing.T) { @@ -257,7 +332,7 @@ func TestPtcShouldBuildOnFullWithLatePayloadMajority(t *testing.T) { require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{ Root: root, PayloadStatus: cltypes.PayloadStatusFull, - })) + }, f.Slot())) } func TestPtcShouldBuildOnFullIgnoresVotesBeforePreviousSlot(t *testing.T) { @@ -270,7 +345,22 @@ func TestPtcShouldBuildOnFullIgnoresVotesBeforePreviousSlot(t *testing.T) { require.True(t, f.ShouldBuildOnFull(ForkChoiceNode{ Root: root, PayloadStatus: cltypes.PayloadStatusFull, - })) + }, f.Slot())) +} + +func TestShouldBuildOnFullUsesExplicitTargetSlot(t *testing.T) { + root := common.HexToHash("0x08") + f := newPtcVoteTestStore(root) + f.forkGraph.(ptcVoteForkGraph).blocks[root].Block.Slot = 10 + f.payloadDataAvailabilityVote.Store(root, ptcVotes(0, ptcVoteThreshold()+1)) + f.payloadTimelinessVote.Store(root, ptcVotes(0, ptcVoteThreshold()+1)) + + require.True(t, f.ShouldBuildOnFull(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusFull}, 12)) + require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusEmpty}, 12)) + require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusPending}, 12)) + require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusFull}, 11)) + require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusEmpty}, 11)) + require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusPending}, 11)) } func TestPtcIsPreviousSlotPayloadDecision(t *testing.T) { @@ -297,7 +387,7 @@ func TestPtcIsPreviousSlotPayloadDecision(t *testing.T) { })) } -func TestGloasForkChoiceRequiresVerifiedPayload(t *testing.T) { +func TestGloasForkChoiceUsesPersistedPayload(t *testing.T) { root := common.HexToHash("0x1234") tests := []struct { @@ -307,10 +397,10 @@ func TestGloasForkChoiceRequiresVerifiedPayload(t *testing.T) { wantFullChild bool }{ { - name: "envelope present but not verified means EMPTY only", + name: "envelope present while EL syncs produces FULL child", hasEnvelope: true, verified: false, - wantFullChild: false, + wantFullChild: true, }, { name: "envelope present and verified produces FULL child", @@ -352,11 +442,26 @@ func TestIsPayloadVerifiedStrictSemantics(t *testing.T) { require.False(t, f.IsPayloadVerified(root)) }) + t.Run("missing envelope is locally unavailable in both vote directions", func(t *testing.T) { + f := newPayloadVoteTestStore(t, root, false, false) + require.False(t, f.payloadTimeliness(root, true)) + require.True(t, f.payloadTimeliness(root, false)) + require.False(t, f.payloadDataAvailability(root, true)) + require.True(t, f.payloadDataAvailability(root, false)) + }) + t.Run("EL-verified and envelope present", func(t *testing.T) { f := newPayloadVoteTestStore(t, root, true, true) require.True(t, f.IsPayloadVerified(root)) }) + t.Run("EL-verified before envelope publication", func(t *testing.T) { + f := newPayloadVoteTestStore(t, root, false, false) + f.MarkPayloadVerified(root, common.HexToHash("0xabcd")) + require.False(t, f.HasEnvelope(root)) + require.False(t, f.IsPayloadVerified(root)) + }) + t.Run("mark verified", func(t *testing.T) { f := newPayloadVoteTestStore(t, root, true, false) execHash := common.HexToHash("0xabcd") @@ -387,6 +492,8 @@ func TestMarkPayloadInvalidRecordsELRejection(t *testing.T) { f.MarkPayloadInvalid(root, execHash) require.False(t, f.IsPayloadVerified(root)) + children := f.getNodeChildren(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusPending}, nil) + require.False(t, hasPayloadStatus(children, cltypes.PayloadStatusFull)) status, ok := f.GetRecentExecutionPayloadStatus(execHash) require.True(t, ok) require.Equal(t, execution_client.PayloadStatus(execution_client.PayloadStatusInvalidated), status) @@ -396,6 +503,247 @@ func TestMarkPayloadInvalidRecordsELRejection(t *testing.T) { require.Equal(t, root, invalidatedHeader) } +func TestPayloadValidationResultAfterPruneDoesNotRestoreStatus(t *testing.T) { + root := common.HexToHash("0x5678") + retained := false + accepted := map[common.Hash]bool{} + f := newPayloadVoteTestStore(t, root, true, false) + f.forkGraph = payloadVoteForkGraph{hasEnvelope: true, retained: &retained, acceptedPayloads: accepted} + envelope := &cltypes.ExecutionPayloadEnvelope{Payload: &cltypes.Eth1Block{BlockHash: common.HexToHash("0xabcd")}} + block := &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{}} + + err := f.applyPayloadValidationResultLocked(execution_client.PayloadStatusValidated, nil, envelope, block, root) + require.ErrorIs(t, err, ErrIgnore) + require.Empty(t, accepted) + _, ok := f.GetRecentExecutionPayloadStatusByRoot(root) + require.False(t, ok) +} + +func TestInvalidPayloadRemainsUnavailableAfterRootStatusEviction(t *testing.T) { + root := common.HexToHash("0x5678") + invalidatedHeader := common.Hash{} + f := newPayloadVoteTestStore(t, root, true, false) + f.forkGraph = payloadVoteForkGraph{hasEnvelope: true, invalidatedHeader: &invalidatedHeader} + statusByRoot, err := lru.New[common.Hash, execution_client.PayloadStatus](1) + require.NoError(t, err) + f.payloadStatusByRoot = statusByRoot + + f.MarkPayloadInvalid(root, common.HexToHash("0xabcd")) + f.payloadStatusByRoot.Add(common.HexToHash("0x9999"), execution_client.PayloadStatusValidated) + _, cached := f.payloadStatusByRoot.Get(root) + require.False(t, cached) + status, found := f.GetRecentExecutionPayloadStatusByRoot(root) + require.True(t, found) + require.Equal(t, execution_client.PayloadStatus(execution_client.PayloadStatusInvalidated), status) + require.Equal(t, root, invalidatedHeader) + + require.False(t, f.isPayloadAvailable(root)) + require.False(t, f.IsPayloadVerified(root)) +} + +func TestPayloadAvailabilityByEngineStatus(t *testing.T) { + root := common.HexToHash("0x5678") + for _, test := range []struct { + name string + status execution_client.PayloadStatus + want bool + }{ + {name: "engine error", status: execution_client.PayloadStatusNone}, + {name: "optimistic", status: execution_client.PayloadStatusNotValidated, want: true}, + } { + t.Run(test.name, func(t *testing.T) { + f := newPayloadVoteTestStore(t, root, true, false) + f.payloadStatusByRoot.Add(root, test.status) + + require.Equal(t, test.want, f.isPayloadAvailable(root)) + require.False(t, f.IsPayloadVerified(root)) + }) + } +} + +func TestValidateParentPayloadPathUsesValidationAvailability(t *testing.T) { + cfg := &clparams.MainnetBeaconConfig + parentRoot := common.HexToHash("0x5678") + executionHash := common.HexToHash("0xabcd") + parent := cltypes.NewSignedBeaconBlock(cfg, clparams.GloasVersion) + parent.Block.Body.SignedExecutionPayloadBid = &cltypes.SignedExecutionPayloadBid{Message: &cltypes.ExecutionPayloadBid{BlockHash: executionHash}} + child := cltypes.NewBeaconBlock(cfg, clparams.GloasVersion) + child.ParentRoot = parentRoot + child.Body.SignedExecutionPayloadBid = &cltypes.SignedExecutionPayloadBid{Message: &cltypes.ExecutionPayloadBid{ParentBlockHash: executionHash}} + + for _, test := range []struct { + name string + status execution_client.PayloadStatus + withStatus bool + wantErr bool + }{ + {name: "engine error", status: execution_client.PayloadStatusNone, withStatus: true, wantErr: true}, + {name: "optimistic", status: execution_client.PayloadStatusNotValidated, withStatus: true}, + {name: "status absent"}, + } { + t.Run(test.name, func(t *testing.T) { + f := newPayloadVoteTestStore(t, parentRoot, true, false) + f.forkGraph = ptcVoteForkGraph{ + envelopes: map[common.Hash]bool{parentRoot: true}, + blocks: map[common.Hash]*cltypes.SignedBeaconBlock{parentRoot: parent}, + } + if test.withStatus { + f.payloadStatusByRoot.Add(parentRoot, test.status) + } + + err := f.validateParentPayloadPath(child) + if test.wantErr { + require.ErrorIs(t, err, ErrParentEnvelopePending) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestApplyPayloadValidationResultRecordsRootAvailability(t *testing.T) { + root := common.HexToHash("0x5678") + for _, test := range []struct { + name string + status execution_client.PayloadStatus + wantErr error + available bool + }{ + {name: "engine error", status: execution_client.PayloadStatusNone, wantErr: errELBehind}, + {name: "optimistic", status: execution_client.PayloadStatusNotValidated, available: true}, + } { + t.Run(test.name, func(t *testing.T) { + f := newPayloadVoteTestStore(t, root, true, false) + unavailableRoot := common.Hash{} + invalidRoot := common.Hash{} + f.forkGraph = payloadVoteForkGraph{ + hasEnvelope: true, + invalidatedHeader: &invalidRoot, + unavailablePayload: &unavailableRoot, + acceptedPayloads: make(map[common.Hash]bool), + } + gasLimits, err := lru.New[common.Hash, uint64](16) + require.NoError(t, err) + f.executionPayloadGasLimit = gasLimits + statusByRoot, err := lru.New[common.Hash, execution_client.PayloadStatus](1) + require.NoError(t, err) + f.payloadStatusByRoot = statusByRoot + f.headHash = root + f.headPayloadStatus = cltypes.PayloadStatusFull + envelope := cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig) + envelope.Payload.BlockHash = common.HexToHash("0xabcd") + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + + var validationErr error + if test.status == execution_client.PayloadStatusNone { + validationErr = errors.New("engine unavailable") + } + err = f.applyPayloadValidationResultLocked(test.status, validationErr, envelope, block, root) + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + } else { + require.NoError(t, err) + } + status, found := f.GetRecentExecutionPayloadStatusByRoot(root) + require.True(t, found) + require.Equal(t, test.status, status) + f.payloadStatusByRoot.Add(common.HexToHash("0x9999"), execution_client.PayloadStatusValidated) + require.Equal(t, test.available, f.isPayloadAvailable(root)) + require.Equal(t, common.Hash{}, f.headHash) + require.Equal(t, cltypes.PayloadStatusPending, f.headPayloadStatus) + }) + } +} + +func TestPayloadStatusTransitionsUpdateDurableAvailability(t *testing.T) { + root := common.HexToHash("0x5678") + execHash := common.HexToHash("0xabcd") + for _, test := range []struct { + name string + initial execution_client.PayloadStatus + next execution_client.PayloadStatus + available bool + verified bool + changed bool + effective execution_client.PayloadStatus + }{ + {name: "none to optimistic", initial: execution_client.PayloadStatusNone, next: execution_client.PayloadStatusNotValidated, available: true, changed: true, effective: execution_client.PayloadStatusNotValidated}, + {name: "none to validated", initial: execution_client.PayloadStatusNone, next: execution_client.PayloadStatusValidated, available: true, verified: true, changed: true, effective: execution_client.PayloadStatusValidated}, + {name: "none to invalidated", initial: execution_client.PayloadStatusNone, next: execution_client.PayloadStatusInvalidated, changed: true, effective: execution_client.PayloadStatusInvalidated}, + {name: "optimistic to none", initial: execution_client.PayloadStatusNotValidated, next: execution_client.PayloadStatusNone, available: true, effective: execution_client.PayloadStatusNotValidated}, + {name: "validated to none", initial: execution_client.PayloadStatusValidated, next: execution_client.PayloadStatusNone, available: true, verified: true, effective: execution_client.PayloadStatusValidated}, + {name: "validated to optimistic", initial: execution_client.PayloadStatusValidated, next: execution_client.PayloadStatusNotValidated, available: true, verified: true, effective: execution_client.PayloadStatusValidated}, + {name: "invalidated to none", initial: execution_client.PayloadStatusInvalidated, next: execution_client.PayloadStatusNone, effective: execution_client.PayloadStatusInvalidated}, + {name: "invalidated to optimistic", initial: execution_client.PayloadStatusInvalidated, next: execution_client.PayloadStatusNotValidated, effective: execution_client.PayloadStatusInvalidated}, + {name: "invalidated to validated", initial: execution_client.PayloadStatusInvalidated, next: execution_client.PayloadStatusValidated, effective: execution_client.PayloadStatusInvalidated}, + } { + t.Run(test.name, func(t *testing.T) { + unavailableRoot := common.Hash{} + invalidRoot := common.Hash{} + f := newPayloadVoteTestStore(t, root, true, false) + f.forkGraph = payloadVoteForkGraph{ + hasEnvelope: true, + invalidatedHeader: &invalidRoot, + unavailablePayload: &unavailableRoot, + acceptedPayloads: make(map[common.Hash]bool), + } + statusByRoot, err := lru.New[common.Hash, execution_client.PayloadStatus](1) + require.NoError(t, err) + f.payloadStatusByRoot = statusByRoot + + f.MarkPayloadStatus(root, execHash, test.initial) + f.payloadStatusByRoot.Add(common.HexToHash("0x9999"), execution_client.PayloadStatusValidated) + f.headHash = root + f.headPayloadStatus = cltypes.PayloadStatusFull + + effective := f.MarkPayloadStatus(root, execHash, test.next) + require.Equal(t, test.effective, effective) + require.Equal(t, test.available, f.isPayloadAvailable(root)) + require.Equal(t, test.verified, f.IsPayloadVerified(root)) + f.verifiedExecutionPayload.Add(common.HexToHash("0x9999"), struct{}{}) + require.Equal(t, test.verified, f.IsPayloadVerified(root)) + if test.changed { + require.Equal(t, common.Hash{}, f.headHash) + require.Equal(t, cltypes.PayloadStatusPending, f.headPayloadStatus) + } else { + require.Equal(t, root, f.headHash) + require.Equal(t, cltypes.PayloadStatusFull, f.headPayloadStatus) + } + }) + } +} + +func TestPayloadStatusGetterUsesDurableAuthorityAfterEviction(t *testing.T) { + root := common.HexToHash("0x5678") + for _, status := range []execution_client.PayloadStatus{ + execution_client.PayloadStatusNone, + execution_client.PayloadStatusNotValidated, + execution_client.PayloadStatusValidated, + execution_client.PayloadStatusInvalidated, + } { + t.Run(fmt.Sprintf("status_%d", status), func(t *testing.T) { + unavailableRoot := common.Hash{} + invalidRoot := common.Hash{} + f := newPayloadVoteTestStore(t, root, true, false) + f.forkGraph = payloadVoteForkGraph{ + hasEnvelope: true, + invalidatedHeader: &invalidRoot, + unavailablePayload: &unavailableRoot, + acceptedPayloads: make(map[common.Hash]bool), + } + statusByRoot, err := lru.New[common.Hash, execution_client.PayloadStatus](1) + require.NoError(t, err) + f.payloadStatusByRoot = statusByRoot + + f.MarkPayloadStatus(root, common.HexToHash("0xabcd"), status) + f.payloadStatusByRoot.Add(common.HexToHash("0x9999"), execution_client.PayloadStatusValidated) + got, found := f.GetRecentExecutionPayloadStatusByRoot(root) + require.True(t, found) + require.Equal(t, status, got) + }) + } +} + func TestStoreAnchorEnvelopePersistsWithoutMarkingVerified(t *testing.T) { root := common.HexToHash("0x5678") execHash := common.HexToHash("0xabcd") @@ -474,9 +822,13 @@ func newPayloadVoteTestStore(t *testing.T, root common.Hash, hasEnvelope, verifi eth2Roots, err := lru.New[common.Hash, common.Hash](16) require.NoError(t, err) + acceptedPayloads := map[common.Hash]bool{} + if verified { + acceptedPayloads[root] = true + } f := &ForkChoiceStore{ beaconCfg: &clparams.MainnetBeaconConfig, - forkGraph: payloadVoteForkGraph{hasEnvelope: hasEnvelope}, + forkGraph: payloadVoteForkGraph{hasEnvelope: hasEnvelope, acceptedPayloads: acceptedPayloads}, eth2Roots: eth2Roots, verifiedExecutionPayload: verifiedExecutionPayload, executionPayloadStatus: executionPayloadStatus, diff --git a/cl/phase1/forkchoice/pending_el_payload_test.go b/cl/phase1/forkchoice/pending_el_payload_test.go index ec55fe6d27c..0b51894015d 100644 --- a/cl/phase1/forkchoice/pending_el_payload_test.go +++ b/cl/phase1/forkchoice/pending_el_payload_test.go @@ -4,10 +4,25 @@ import ( "testing" "github.com/erigontech/erigon/cl/cltypes" + "github.com/erigontech/erigon/cl/phase1/execution_client" "github.com/erigontech/erigon/common" "github.com/stretchr/testify/require" ) +func TestStalePayloadRetryAfterPruneIsDropped(t *testing.T) { + root := common.HexToHash("0x1234") + retained := false + accepted := map[common.Hash]bool{} + f := &ForkChoiceStore{forkGraph: payloadVoteForkGraph{hasEnvelope: true, retained: &retained, acceptedPayloads: accepted}} + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: &cltypes.ExecutionPayloadEnvelope{BeaconBlockRoot: root}} + + _, applied := f.MarkPayloadStatusIfRetained(root, common.HexToHash("0xabcd"), execution_client.PayloadStatusNotValidated) + require.False(t, applied) + require.Empty(t, accepted) + f.RequeuePendingELPayload(PendingELPayload{Block: &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 1}}, Envelope: envelope}) + require.Empty(t, f.DrainPendingELPayloads()) +} + func TestPendingELPayloadsDropOldestAtCap(t *testing.T) { f := &ForkChoiceStore{} diff --git a/cl/phase1/forkchoice/timing.go b/cl/phase1/forkchoice/timing.go index 03ae1466485..8daadbcd472 100644 --- a/cl/phase1/forkchoice/timing.go +++ b/cl/phase1/forkchoice/timing.go @@ -125,13 +125,17 @@ func (f *ForkChoiceStore) recordBlockTimeliness(block *cltypes.BeaconBlock, bloc func (f *ForkChoiceStore) getDependentRoot(root common.Hash) common.Hash { epoch := f.computeEpochAtSlot(f.Slot()) - if epoch <= f.beaconCfg.MinSeedLookahead { - return common.Hash{} - } - dependentSlot := f.computeStartSlotAtEpoch(epoch-f.beaconCfg.MinSeedLookahead) - 1 + dependentSlot := computeShufflingDependentSlot(epoch, f.beaconCfg.MinSeedLookahead, f.beaconCfg.SlotsPerEpoch) return f.getAncestor(f.getNodeForRoot(root), dependentSlot).Root } +func computeShufflingDependentSlot(epoch, minSeedLookahead, slotsPerEpoch uint64) uint64 { + if epoch <= minSeedLookahead { + return 0 + } + return (epoch-minSeedLookahead)*slotsPerEpoch - 1 +} + // updateProposerBoostRoot implements update_proposer_boost_root from the spec. func (f *ForkChoiceStore) updateProposerBoostRoot(headRoot common.Hash, blockRoot common.Hash) { timeliness, ok := f.getBlockTimeliness(blockRoot) @@ -189,20 +193,20 @@ func (f *ForkChoiceStore) shouldApplyProposerBoostGloas(proposerBoostRoot common } func (f *ForkChoiceStore) shouldApplyProposerBoostGloasWith(proposerBoostRoot common.Hash, isHeadWeak func(common.Hash) bool) bool { - boostBlock, ok := f.forkGraph.GetBlock(proposerBoostRoot) - if !ok || boostBlock == nil { + boostBlock, ok := f.forkGraph.GetHeader(proposerBoostRoot) + if !ok { return false } - parentRoot := boostBlock.Block.ParentRoot - slot := boostBlock.Block.Slot + parentRoot := boostBlock.ParentRoot + slot := boostBlock.Slot - parentBlock, ok := f.forkGraph.GetBlock(parentRoot) - if !ok || parentBlock == nil { + parentBlock, ok := f.forkGraph.GetHeader(parentRoot) + if !ok { return false } - if parentBlock.Block.Slot+1 < slot { + if parentBlock.Slot+1 < slot { return true } @@ -210,7 +214,7 @@ func (f *ForkChoiceStore) shouldApplyProposerBoostGloasWith(proposerBoostRoot co return true } - parentProposerIndex := parentBlock.Block.ProposerIndex + parentProposerIndex := parentBlock.ProposerIndex hasEquivocation := false f.blockTimeliness.Range(func(key, value any) bool { root := key.(common.Hash) @@ -222,11 +226,11 @@ func (f *ForkChoiceStore) shouldApplyProposerBoostGloasWith(proposerBoostRoot co if !timeliness[clparams.PtcTimelinessIndex] { return true } - blk, blkOk := f.forkGraph.GetBlock(root) - if !blkOk || blk == nil { + blk, blkOk := f.forkGraph.GetHeader(root) + if !blkOk { return true } - if blk.Block.ProposerIndex == parentProposerIndex && blk.Block.Slot+1 == slot { + if blk.ProposerIndex == parentProposerIndex && blk.Slot+1 == slot { hasEquivocation = true return false } @@ -318,8 +322,7 @@ func (f *ForkChoiceStore) isHeadWeakWith(root common.Hash, checkpointState *chec } vi := int(validatorIndex) if vi < checkpointState.validatorSetSize && - readFromBitset(checkpointState.actives, vi) && - !readFromBitset(checkpointState.slasheds, vi) { + readFromBitset(checkpointState.actives, vi) { weight += checkpointState.balances[vi] } } diff --git a/cl/phase1/forkchoice/timing_test.go b/cl/phase1/forkchoice/timing_test.go index 43a82ecc09d..f3100c33a69 100644 --- a/cl/phase1/forkchoice/timing_test.go +++ b/cl/phase1/forkchoice/timing_test.go @@ -79,6 +79,12 @@ func TestGetPayloadAttestationDueMs(t *testing.T) { require.Equal(t, uint64(9000), f.getPayloadAttestationDueMs(10)) } +func TestComputeShufflingDependentSlotUsesGenesisInEarlyEpochs(t *testing.T) { + require.Zero(t, computeShufflingDependentSlot(0, 1, 32)) + require.Zero(t, computeShufflingDependentSlot(1, 1, 32)) + require.Equal(t, uint64(31), computeShufflingDependentSlot(2, 1, 32)) +} + func TestShouldApplyProposerBoost_PreGloas(t *testing.T) { // Mainnet config: 12s slots, 3 intervals, GLOAS not activated // Threshold: 12/3 = 4 seconds into slot diff --git a/cl/phase1/network/services/execution_payload_bid_service.go b/cl/phase1/network/services/execution_payload_bid_service.go index 0d2c413a9c5..d59aff89d73 100644 --- a/cl/phase1/network/services/execution_payload_bid_service.go +++ b/cl/phase1/network/services/execution_payload_bid_service.go @@ -18,8 +18,8 @@ package services import ( "context" - "errors" "fmt" + "math" "sync" "sync/atomic" "time" @@ -40,18 +40,61 @@ import ( "github.com/libp2p/go-libp2p/core/peer" ) -// seenBidKey tracks seen bids per (builderIndex, slot). -// Spec: [IGNORE] The signed_execution_payload_bid is the first valid bid from this builder for this slot. type seenBidKey struct { - builderIndex uint64 - slot uint64 + builderIndex uint64 + slot uint64 + parentBlockHash common.Hash + parentBlockRoot common.Hash +} + +type seenBidStore struct { + mu sync.RWMutex + bySlot map[uint64]map[seenBidKey]struct{} +} + +func newSeenBidStore() *seenBidStore { + return &seenBidStore{bySlot: make(map[uint64]map[seenBidKey]struct{})} +} + +func (s *seenBidStore) Contains(key seenBidKey) bool { + s.mu.RLock() + defer s.mu.RUnlock() + _, ok := s.bySlot[key.slot][key] + return ok +} + +func (s *seenBidStore) Add(key seenBidKey) { + s.mu.Lock() + defer s.mu.Unlock() + if s.bySlot[key.slot] == nil { + s.bySlot[key.slot] = make(map[seenBidKey]struct{}) + } + s.bySlot[key.slot][key] = struct{}{} +} + +func (s *seenBidStore) PruneExpired(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + for slot := range s.bySlot { + if isPastBidWindow(clock, beaconCfg, now, slot) { + delete(s.bySlot, slot) + } + } +} + +func newSeenBidKey(bid *cltypes.ExecutionPayloadBid) seenBidKey { + return seenBidKey{ + builderIndex: bid.BuilderIndex, + slot: bid.Slot, + parentBlockHash: bid.ParentBlockHash, + parentBlockRoot: bid.ParentBlockRoot, + } } // pendingBidKey tracks bids waiting for proposer preferences. type pendingBidKey struct { - builderIndex uint64 - slot uint64 - messageRoot common.Hash + seenBidKey + messageRoot common.Hash } // pendingBidJob represents a pending bid waiting for proposer preferences to arrive. @@ -66,22 +109,20 @@ type bidValidationStateKey struct { } type bidValidationStateEntry struct { - mu sync.Mutex - state *state.CachingBeaconState + mu sync.Mutex + state *state.CachingBeaconState + parentSlot uint64 + parentRandao common.Hash } var errBidDependencyUnavailable = fmt.Errorf("%w: bid dependency unavailable", ErrIgnore) const ( - // seenBidCacheSize: multiple builders can bid per slot. - // With clock disparity we may see bids for ~2 slots. - // 256 builders * 2 slots = 512 provides safety margin. - seenBidCacheSize = 512 - pendingBidExpiry = 12 * time.Second // 1 slot - pendingBidCheckInterval = 100 * time.Millisecond maxPendingBids = 1024 + maxPendingBidsPerTuple = 4 bidValidationStateCacheSize = 4 bidValidationStateTTLSlots = 2 + gloasMaximumClockDisparity = 500 * time.Millisecond ) type executionPayloadBidService struct { @@ -91,8 +132,9 @@ type executionPayloadBidService struct { beaconCfg *clparams.BeaconChainConfig epbsPool *pool.EpbsPool emitters *beaconevents.EventEmitter + now func() time.Time - seenCache *lru.Cache[seenBidKey, struct{}] + seenCache *seenBidStore bidStoreMu sync.Mutex validationStateMu sync.Mutex validationStateCache *lru.CacheWithTTL[bidValidationStateKey, *bidValidationStateEntry] @@ -100,6 +142,7 @@ type executionPayloadBidService struct { // Pending bids waiting for proposer preferences pendingBids sync.Map // pendingBidKey -> *pendingBidJob pendingMu sync.Mutex + pendingByKey map[seenBidKey]int pendingCount atomic.Int32 pendingCond *sync.Cond } @@ -107,7 +150,7 @@ type executionPayloadBidService struct { // NewExecutionPayloadBidService creates a new execution payload bid gossip service. // [New in Gloas:EIP7732] func NewExecutionPayloadBidService( - ctx context.Context, + _ context.Context, syncedDataManager synced_data.SyncedData, forkchoiceStore forkchoice.ForkChoiceStorageReader, ethClock eth_clock.EthereumClock, @@ -115,10 +158,6 @@ func NewExecutionPayloadBidService( epbsPool *pool.EpbsPool, emitters *beaconevents.EventEmitter, ) ExecutionPayloadBidService { - seenCache, err := lru.New[seenBidKey, struct{}]("seen_execution_payload_bids", seenBidCacheSize) - if err != nil { - panic(err) - } validationStateCache := lru.NewWithTTL[bidValidationStateKey, *bidValidationStateEntry]( "execution_payload_bid_validation_states", bidValidationStateCacheSize, @@ -131,11 +170,12 @@ func NewExecutionPayloadBidService( beaconCfg: beaconCfg, epbsPool: epbsPool, emitters: emitters, - seenCache: seenCache, + now: time.Now, + seenCache: newSeenBidStore(), validationStateCache: validationStateCache, pendingCond: sync.NewCond(&sync.Mutex{}), + pendingByKey: make(map[seenBidKey]int), } - go s.loop(ctx) return s } @@ -169,54 +209,129 @@ func (s *executionPayloadBidService) ProcessMessage(ctx context.Context, _ *uint "value", bid.Value, "parentBlockHash", bid.ParentBlockHash) + now := s.now() + s.epbsPool.HighestBids.PruneSlots(func(entrySlot uint64) bool { + return isPastBidWindow(s.ethClock, s.beaconCfg, now, entrySlot) + }) + s.epbsPool.ProposerPreferences.PruneSlots(func(entrySlot uint64) bool { + return isPastBidWindow(s.ethClock, s.beaconCfg, now, entrySlot) + }) // [IGNORE] bid.slot is the current or next slot - currentSlot := s.ethClock.GetCurrentSlot() - if slot != currentSlot && slot != currentSlot+1 { - return fmt.Errorf("%w: bid slot %d is not current (%d) or next slot", ErrIgnore, slot, currentSlot) + if !isCurrentOrNextSlot(s.ethClock, s.beaconCfg, now, slot, gloasMaximumClockDisparity) { + return fmt.Errorf("%w: bid slot %d is not current or next slot", ErrIgnore, slot) } - seenKey := seenBidKey{builderIndex: builderIndex, slot: slot} + seenKey := newSeenBidKey(bid) if s.seenCache.Contains(seenKey) { return fmt.Errorf("%w: already seen bid from builder %d for slot %d", ErrIgnore, builderIndex, slot) } - if err := s.validateBidStateless(bid); err != nil { + if err := s.validateHighestBid(bid); err != nil { return err } - if err := s.validateHighestBid(bid); err != nil { + parentHeader, ok := s.forkchoiceStore.GetHeader(bid.ParentBlockRoot) + if ok && slot <= parentHeader.Slot { + return fmt.Errorf("bid slot %d is not greater than parent block slot %d", slot, parentHeader.Slot) + } + if err := s.validateBidStateless(bid); err != nil { return err } - + if !ok { + return fmt.Errorf("%w: parent_block_root %v not known in fork choice", ErrIgnore, bid.ParentBlockRoot) + } preferences, ok, err := s.matchingProposerPreferences(msg) if err != nil { - if errors.Is(err, errBidDependencyUnavailable) { - s.queuePendingBid(msg) - log.Trace("Queued execution payload bid waiting for dependencies", - "slot", slot, "builderIndex", builderIndex, "err", err) - return fmt.Errorf("%w: %w: %w", ErrIgnore, ErrBidQueued, err) - } return err } if !ok { - // Queue as pending — preferences may arrive later - s.queuePendingBid(msg) - log.Trace("Queued execution payload bid waiting for proposer preferences", - "slot", slot, "builderIndex", builderIndex) - return fmt.Errorf("%w: %w: proposer preferences not available", ErrIgnore, ErrBidQueued) - } - - if err := s.validateAndStoreBid(msg, preferences); err != nil { - if errors.Is(err, errBidDependencyUnavailable) { - s.queuePendingBid(msg) - log.Trace("Queued execution payload bid waiting for dependencies", - "slot", slot, "builderIndex", builderIndex, "err", err) - return fmt.Errorf("%w: %w: %w", ErrIgnore, ErrBidQueued, err) - } + return fmt.Errorf("%w: proposer preferences not available", ErrIgnore) + } + + prefs := preferences.Message + if prefs == nil { + return fmt.Errorf("%w: proposer preferences not available", ErrIgnore) + } + if bid.FeeRecipient != prefs.FeeRecipient { + return fmt.Errorf("%w: bid fee_recipient %v does not match proposer preferences %v", + ErrIgnore, bid.FeeRecipient, prefs.FeeRecipient) + } + if _, ok := s.forkchoiceStore.GetRecentExecutionPayloadStatus(bid.ParentBlockHash); !ok { + return fmt.Errorf("%w: parent_block_hash %v not known in fork choice", ErrIgnore, bid.ParentBlockHash) + } + parentGasLimit, ok := s.forkchoiceStore.GetExecutionPayloadGasLimit(bid.ParentBlockHash) + if !ok { + return fmt.Errorf("%w: gas limit for parent_block_hash %v not available", ErrIgnore, bid.ParentBlockHash) + } + if !IsGasLimitTargetCompatible(parentGasLimit, bid.GasLimit, prefs.TargetGasLimit) { + return fmt.Errorf("%w: bid gas_limit %d not compatible with target %d (parent %d)", + ErrIgnore, bid.GasLimit, prefs.TargetGasLimit, parentGasLimit) + } + compatible, err := s.isBidCompatibleWithHead(bid) + if err != nil { + return err + } + if !compatible { + return fmt.Errorf("%w: bid is not compatible with the current head branch", ErrIgnore) + } + validationStateEntry, err := s.bidValidationState(bid.ParentBlockRoot, bid.Slot) + if err != nil { return err } + if err := s.validateBidAuthentication(msg, validationStateEntry); err != nil { + return err + } + if err := s.storeValidBidAt(msg, now); err != nil { + return err + } + s.emitters.Operation().SendExecutionPayloadBid(msg) + log.Trace("Processed execution payload bid via gossip", "slot", slot, "builderIndex", builderIndex, + "value", bid.Value, "parentBlockHash", bid.ParentBlockHash) return nil } +func isCurrentOrNextSlot(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, now time.Time, slot uint64, disparity time.Duration) bool { + if slot == ^uint64(0) { + return false + } + lowerSlot := slot + if slot > 0 { + lowerSlot-- + } + lowerSlotTime, ok := safeSlotTime(clock, beaconCfg, lowerSlot) + if !ok { + return false + } + upperSlotTime, ok := safeSlotTime(clock, beaconCfg, slot+1) + if !ok { + return false + } + lowerBound := lowerSlotTime.Add(-disparity) + upperBound := upperSlotTime.Add(disparity) + return !now.Before(lowerBound) && !now.After(upperBound) +} + +func safeSlotTime(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, slot uint64) (time.Time, bool) { + if beaconCfg == nil || beaconCfg.SecondsPerSlot == 0 { + return time.Time{}, false + } + genesisTime := clock.GenesisTime() + if slot > (math.MaxUint64-genesisTime)/beaconCfg.SecondsPerSlot { + return time.Time{}, false + } + slotTime := genesisTime + slot*beaconCfg.SecondsPerSlot + if slotTime > math.MaxInt64 { + return time.Time{}, false + } + return time.Unix(int64(slotTime), 0), true +} + +func safeMultiplyUint64(a, b uint64) (uint64, bool) { + if a != 0 && b > math.MaxUint64/a { + return 0, false + } + return a * b, true +} + func (s *executionPayloadBidService) matchingProposerPreferences(msg *cltypes.SignedExecutionPayloadBid) (*cltypes.SignedProposerPreferences, bool, error) { bid := msg.Message if _, ok := s.forkchoiceStore.GetHeader(bid.ParentBlockRoot); !ok { @@ -235,10 +350,10 @@ func (s *executionPayloadBidService) matchingProposerPreferences(msg *cltypes.Si } func (s *executionPayloadBidService) shufflingDependentRoot(root common.Hash, epoch uint64) (common.Hash, error) { - if epoch <= s.beaconCfg.MinSeedLookahead { - return common.Hash{}, fmt.Errorf("%w: cannot compute proposer dependent root for epoch %d before or at min seed lookahead %d", ErrIgnore, epoch, s.beaconCfg.MinSeedLookahead) + dependentSlot := uint64(0) + if epoch > s.beaconCfg.MinSeedLookahead { + dependentSlot = (epoch-s.beaconCfg.MinSeedLookahead)*s.beaconCfg.SlotsPerEpoch - 1 } - dependentSlot := (epoch-s.beaconCfg.MinSeedLookahead)*s.beaconCfg.SlotsPerEpoch - 1 return s.forkchoiceStore.Ancestor(root, dependentSlot).Root, nil } @@ -263,54 +378,34 @@ func bidValidationStateCacheTTL(beaconCfg *clparams.BeaconChainConfig) time.Dura return time.Duration(secondsPerSlot*bidValidationStateTTLSlots) * time.Second } -// validateAndStoreBid performs all remaining validation checks after preferences are confirmed. -func (s *executionPayloadBidService) validateAndStoreBid( - msg *cltypes.SignedExecutionPayloadBid, - preferences *cltypes.SignedProposerPreferences, -) error { +func (s *executionPayloadBidService) validateBidAuthentication(msg *cltypes.SignedExecutionPayloadBid, validationStateEntry *bidValidationStateEntry) error { bid := msg.Message - slot := bid.Slot - builderIndex := bid.BuilderIndex - prefs := preferences.Message - - // [REJECT] fee_recipient must match proposer preferences - if bid.FeeRecipient != prefs.FeeRecipient { - return fmt.Errorf("bid fee_recipient %v does not match proposer preferences %v", - bid.FeeRecipient, prefs.FeeRecipient) - } - - // [IGNORE] parent_block_root is known in fork choice - parentHeader, ok := s.forkchoiceStore.GetHeader(bid.ParentBlockRoot) - if !ok { - return fmt.Errorf("%w: parent_block_root %v not known in fork choice", - ErrIgnore, bid.ParentBlockRoot) - } - if slot <= parentHeader.Slot { - return fmt.Errorf("bid slot %d is not greater than parent block slot %d", slot, parentHeader.Slot) - } - if err := s.validateHighestBid(bid); err != nil { - return err - } - validationStateEntry, err := s.bidValidationState(bid.ParentBlockRoot, slot) - if err != nil { - return fmt.Errorf("bid validation failed: %w", err) + if bid.Slot <= validationStateEntry.parentSlot { + return fmt.Errorf("bid slot %d is not greater than parent block slot %d", bid.Slot, validationStateEntry.parentSlot) } validationStateEntry.mu.Lock() - validationState := validationStateEntry.state - if bid.PrevRandao != validationState.GetRandaoMixes(state.Epoch(validationState)) { + if bid.PrevRandao != validationStateEntry.parentRandao { validationStateEntry.mu.Unlock() return fmt.Errorf("bid prev_randao does not match parent state randao mix") } + validationState, err := validationStateEntry.state.Copy() + validationStateEntry.mu.Unlock() + if err != nil { + return fmt.Errorf("bid validation failed: failed to copy parent state: %w", err) + } + if validationState.Slot() != bid.Slot { + if err := transition.DefaultMachine.ProcessSlots(validationState, bid.Slot); err != nil { + return fmt.Errorf("bid validation failed: failed to advance parent state: %w", err) + } + } builder, err := s.validateBuilderAvailability(bid, validationState) if err != nil { - validationStateEntry.mu.Unlock() return fmt.Errorf("bid validation failed: %w", err) } builderPubkey := builder.Pubkey epoch := state.GetEpochAtSlot(s.beaconCfg, bid.Slot) domain, err := validationState.GetDomain(s.beaconCfg.DomainBeaconBuilder, epoch) - validationStateEntry.mu.Unlock() if err != nil { return fmt.Errorf("bid validation failed: failed to get domain: %w", err) } @@ -318,46 +413,25 @@ func (s *executionPayloadBidService) validateAndStoreBid( return fmt.Errorf("bid validation failed: %w", err) } - // [IGNORE] parent_block_hash is known in fork choice - if _, ok := s.forkchoiceStore.GetRecentExecutionPayloadStatus(bid.ParentBlockHash); !ok { - return fmt.Errorf("%w: parent_block_hash %v not known in fork choice", - ErrIgnore, bid.ParentBlockHash) - } - - // [IGNORE] gas_limit compatibility check — skipped (not rejected) when parent is absent from the LRU. - if parentGasLimit, ok := s.forkchoiceStore.GetExecutionPayloadGasLimit(bid.ParentBlockHash); ok { - if !IsGasLimitTargetCompatible(parentGasLimit, bid.GasLimit, prefs.TargetGasLimit) { - return fmt.Errorf("%w: bid gas_limit %d not compatible with target %d (parent %d)", - ErrIgnore, bid.GasLimit, prefs.TargetGasLimit, parentGasLimit) - } - } - - if err := s.validateHighestBid(bid); err != nil { - return err - } - - if err := s.storeValidBid(msg); err != nil { - return err - } - - // Emit SSE event for execution_payload_bid [New in Gloas:EIP7732] - s.emitters.Operation().SendExecutionPayloadBid(msg) - - log.Trace("Processed execution payload bid via gossip", - "slot", slot, - "builderIndex", builderIndex, - "value", bid.Value, - "parentBlockHash", bid.ParentBlockHash) - return nil } func (s *executionPayloadBidService) storeValidBid(msg *cltypes.SignedExecutionPayloadBid) error { + return s.storeValidBidAt(msg, time.Time{}) +} + +func (s *executionPayloadBidService) storeValidBidAt(msg *cltypes.SignedExecutionPayloadBid, now time.Time) error { bid := msg.Message - seenKey := seenBidKey{builderIndex: bid.BuilderIndex, slot: bid.Slot} + seenKey := newSeenBidKey(bid) s.bidStoreMu.Lock() defer s.bidStoreMu.Unlock() + if !now.IsZero() { + s.seenCache.PruneExpired(s.ethClock, s.beaconCfg, now) + s.epbsPool.HighestBids.PruneSlots(func(entrySlot uint64) bool { + return isPastBidWindow(s.ethClock, s.beaconCfg, now, entrySlot) + }) + } if s.seenCache.Contains(seenKey) { return fmt.Errorf("%w: already seen bid from builder %d for slot %d", @@ -366,12 +440,65 @@ func (s *executionPayloadBidService) storeValidBid(msg *cltypes.SignedExecutionP if err := s.validateHighestBid(bid); err != nil { return err } - s.seenCache.Add(seenKey, struct{}{}) + s.seenCache.Add(seenKey) bidKey := pool.HighestBidKey{Slot: bid.Slot, ParentBlockHash: bid.ParentBlockHash, ParentBlockRoot: bid.ParentBlockRoot} s.epbsPool.HighestBids.Add(bidKey, msg) return nil } +func (s *executionPayloadBidService) isBidCompatibleWithHead(bid *cltypes.ExecutionPayloadBid) (bool, error) { + headNode, err := s.forkchoiceStore.GetHeadNode() + if err != nil { + return false, fmt.Errorf("%w: head unavailable: %w", errBidDependencyUnavailable, err) + } + headRoot := headNode.Root + headHeader, ok := s.forkchoiceStore.GetHeader(headRoot) + if !ok { + return false, fmt.Errorf("%w: head block header unavailable", errBidDependencyUnavailable) + } + headBlock, hasBlock := s.forkchoiceStore.GetBlock(headRoot) + if hasBlock && headBlock != nil && headBlock.Block != nil && headBlock.Block.Body != nil { + signedHeadBid := headBlock.Block.Body.GetSignedExecutionPayloadBid() + if signedHeadBid != nil && signedHeadBid.Message != nil { + return bidCompatibleWithHead(bid, headRoot, headHeader, signedHeadBid.Message, s.forkchoiceStore.ShouldBuildOnFull(headNode, bid.Slot)), nil + } + if headPayload := headBlock.Block.Body.ExecutionPayload; headPayload != nil { + return bid.ParentBlockRoot == headRoot && bid.ParentBlockHash == headPayload.BlockHash, nil + } + } + headState, err := s.forkchoiceStore.GetStateAtBlockRoot(headRoot, true) + if err != nil || headState == nil { + return false, fmt.Errorf("%w: head state unavailable", errBidDependencyUnavailable) + } + if headState.Version() >= clparams.GloasVersion { + headBid := headState.GetLatestExecutionPayloadBid() + if headBid == nil { + return false, fmt.Errorf("%w: head bid unavailable", errBidDependencyUnavailable) + } + return bidCompatibleWithHead(bid, headRoot, headHeader, headBid, s.forkchoiceStore.ShouldBuildOnFull(headNode, bid.Slot)), nil + } + headPayload := headState.LatestExecutionPayloadHeader() + if headPayload == nil { + return false, fmt.Errorf("%w: head execution payload unavailable", errBidDependencyUnavailable) + } + return bid.ParentBlockRoot == headRoot && bid.ParentBlockHash == headPayload.BlockHash, nil +} + +func bidCompatibleWithHead(bid *cltypes.ExecutionPayloadBid, headRoot common.Hash, headHeader *cltypes.BeaconBlockHeader, headBid *cltypes.ExecutionPayloadBid, buildOnFull bool) bool { + buildsOnParentBlock := bid.ParentBlockRoot == headHeader.ParentRoot + buildsOnParentPayload := bid.ParentBlockHash == headBid.ParentBlockHash + if buildsOnParentBlock && buildsOnParentPayload { + return true + } + if bid.ParentBlockRoot != headRoot { + return false + } + if buildOnFull { + return bid.ParentBlockHash == headBid.BlockHash + } + return buildsOnParentPayload +} + func (s *executionPayloadBidService) validateHighestBid(bid *cltypes.ExecutionPayloadBid) error { bidKey := pool.HighestBidKey{Slot: bid.Slot, ParentBlockHash: bid.ParentBlockHash, ParentBlockRoot: bid.ParentBlockRoot} existing, found := s.epbsPool.HighestBids.Get(bidKey) @@ -412,16 +539,14 @@ func (s *executionPayloadBidService) bidValidationState(parentBlockRoot common.H s.removeBidValidationState(cacheKey, entry) return nil, fmt.Errorf("parent state slot %d is after bid slot %d", parentState.Slot(), bidSlot) } - validationState := parentState - if parentState.Slot() == bidSlot { - entry.state = validationState - return entry, nil - } - if err := transition.DefaultMachine.ProcessSlots(validationState, bidSlot); err != nil { + entry.parentSlot = parentState.Slot() + entry.parentRandao = parentState.GetRandaoMixes(state.Epoch(parentState)) + proposalEpoch := state.GetEpochAtSlot(s.beaconCfg, bidSlot) + if proposalEpoch > state.Epoch(parentState)+s.beaconCfg.MinSeedLookahead { s.removeBidValidationState(cacheKey, entry) - return nil, err + return nil, fmt.Errorf("%w: bid slot is past the parent's proposer lookahead", ErrIgnore) } - entry.state = validationState + entry.state = parentState return entry, nil } @@ -465,20 +590,20 @@ func (s *executionPayloadBidService) validateBuilderAvailability( if builder == nil { return nil, fmt.Errorf("builder %d not found", builderIndex) } - if builder.Version != s.beaconCfg.PayloadBuilderVersion { - return nil, fmt.Errorf("builder %d has unsupported version %d", builderIndex, builder.Version) + if !state.CanBuilderCoverBid(validationState, builderIndex, bid.Value) { + return nil, fmt.Errorf("%w: builder %d cannot cover bid value %d", ErrIgnore, builderIndex, bid.Value) } if !state.IsActiveBuilder(validationState, builderIndex) { return nil, fmt.Errorf("builder %d is not active", builderIndex) } - if !state.CanBuilderCoverBid(validationState, builderIndex, bid.Value) { - return nil, fmt.Errorf("%w: builder %d cannot cover bid value %d", ErrIgnore, builderIndex, bid.Value) + if builder.Version != s.beaconCfg.PayloadBuilderVersion { + return nil, fmt.Errorf("builder %d has unsupported version %d", builderIndex, builder.Version) } return builder, nil } // queuePendingBid adds a bid to the pending queue for later processing when preferences arrive. -func (s *executionPayloadBidService) queuePendingBid(msg *cltypes.SignedExecutionPayloadBid) { +func (s *executionPayloadBidService) queuePendingBid(msg *cltypes.SignedExecutionPayloadBid) bool { key := pendingBidKeyFor(msg) job := &pendingBidJob{ msg: msg, @@ -486,19 +611,29 @@ func (s *executionPayloadBidService) queuePendingBid(msg *cltypes.SignedExecutio } s.pendingMu.Lock() + if s.pendingByKey == nil { + s.pendingByKey = make(map[seenBidKey]int) + } if _, loaded := s.pendingBids.Load(key); loaded { s.pendingMu.Unlock() - return + return false } if s.pendingCount.Load() >= maxPendingBids { s.pendingMu.Unlock() - return + return false + } + tuple := key.seenBidKey + if s.pendingByKey[tuple] >= maxPendingBidsPerTuple { + s.pendingMu.Unlock() + return false } s.pendingBids.Store(key, job) + s.pendingByKey[tuple]++ s.pendingCount.Add(1) s.pendingMu.Unlock() s.signalPendingBids() + return true } func (s *executionPayloadBidService) deletePendingBid(key pendingBidKey, job *pendingBidJob) bool { @@ -509,6 +644,10 @@ func (s *executionPayloadBidService) deletePendingBid(key pendingBidKey, job *pe return false } s.pendingBids.Delete(key) + s.pendingByKey[key.seenBidKey]-- + if s.pendingByKey[key.seenBidKey] == 0 { + delete(s.pendingByKey, key.seenBidKey) + } s.pendingCount.Add(-1) return true } @@ -521,112 +660,5 @@ func (s *executionPayloadBidService) signalPendingBids() { func pendingBidKeyFor(msg *cltypes.SignedExecutionPayloadBid) pendingBidKey { root, _ := msg.HashSSZ() - return pendingBidKey{ - builderIndex: msg.Message.BuilderIndex, - slot: msg.Message.Slot, - messageRoot: common.Hash(root), - } -} - -// loop is the background goroutine that processes pending bids. -func (s *executionPayloadBidService) loop(ctx context.Context) { - // Wake any blocked Wait() on context cancellation to prevent deadlock. - go func() { - <-ctx.Done() - s.pendingCond.L.Lock() - s.pendingCond.Broadcast() - s.pendingCond.L.Unlock() - }() - - for { - // Wait until there are pending bids - s.pendingCond.L.Lock() - for s.pendingCount.Load() == 0 { - select { - case <-ctx.Done(): - s.pendingCond.L.Unlock() - return - default: - } - s.pendingCond.Wait() - } - s.pendingCond.L.Unlock() - - // Poll until all pending bids are processed - ticker := time.NewTicker(pendingBidCheckInterval) - for s.pendingCount.Load() > 0 { - select { - case <-ctx.Done(): - ticker.Stop() - return - case <-ticker.C: - s.processPendingBids() - } - } - ticker.Stop() - } -} - -// processPendingBids checks pending bids whose proposer preferences may have arrived. -func (s *executionPayloadBidService) processPendingBids() { - s.pendingBids.Range(func(key, value any) bool { - pendingKey := key.(pendingBidKey) - job := value.(*pendingBidJob) - - // Check expiry - if time.Since(job.creationTime) > pendingBidExpiry { - if s.deletePendingBid(pendingKey, job) { - log.Trace("Pending execution payload bid expired", - "slot", pendingKey.slot, "builderIndex", pendingKey.builderIndex) - } - return true - } - - // Check if bid slot is still valid - currentSlot := s.ethClock.GetCurrentSlot() - if pendingKey.slot != currentSlot && pendingKey.slot != currentSlot+1 { - if s.deletePendingBid(pendingKey, job) { - log.Trace("Pending execution payload bid slot expired", - "slot", pendingKey.slot, "builderIndex", pendingKey.builderIndex) - } - return true - } - - if s.seenCache.Contains(seenBidKey{builderIndex: pendingKey.builderIndex, slot: pendingKey.slot}) { - s.deletePendingBid(pendingKey, job) - return true - } - - preferences, ok, err := s.matchingProposerPreferences(job.msg) - if err != nil { - if errors.Is(err, errBidDependencyUnavailable) { - return true - } - if s.deletePendingBid(pendingKey, job) { - log.Trace("Failed to match pending execution payload bid", - "slot", pendingKey.slot, - "builderIndex", pendingKey.builderIndex, - "err", err) - } - return true - } - if !ok { - return true // Preferences still not here, keep waiting - } - - if err := s.validateAndStoreBid(job.msg, preferences); err != nil { - if errors.Is(err, errBidDependencyUnavailable) { - return true - } - if s.deletePendingBid(pendingKey, job) { - log.Trace("Failed to process pending execution payload bid", - "slot", pendingKey.slot, - "builderIndex", pendingKey.builderIndex, - "err", err) - } - return true - } - s.deletePendingBid(pendingKey, job) - return true - }) + return pendingBidKey{seenBidKey: newSeenBidKey(msg.Message), messageRoot: common.Hash(root)} } diff --git a/cl/phase1/network/services/execution_payload_bid_service_test.go b/cl/phase1/network/services/execution_payload_bid_service_test.go index 6c2097f479b..45d71f0b19e 100644 --- a/cl/phase1/network/services/execution_payload_bid_service_test.go +++ b/cl/phase1/network/services/execution_payload_bid_service_test.go @@ -3,8 +3,10 @@ package services import ( "context" "errors" + "math" "sync" "testing" + "time" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -33,6 +35,10 @@ func setupExecutionPayloadBidService(t *testing.T, ctrl *gomock.Controller) ( ) { mockSyncedData := synced_data_mock.NewMockSyncedData(ctrl) ethClockMock := eth_clock.NewMockEthereumClock(ctrl) + ethClockMock.EXPECT().GenesisTime().Return(uint64(0)).AnyTimes() + ethClockMock.EXPECT().GetSlotTime(gomock.Any()).DoAndReturn(func(slot uint64) time.Time { + return time.Unix(int64(slot*12), 0) + }).AnyTimes() fcMock := forkchoice_mock.NewForkChoiceStorageMock(t) epbsPool := pool.NewEpbsPool() beaconCfg := clparams.MainnetBeaconConfig @@ -41,13 +47,23 @@ func setupExecutionPayloadBidService(t *testing.T, ctrl *gomock.Controller) ( beaconCfg.MinSeedLookahead = 1 beaconCfg.DomainBeaconBuilder = [4]byte{0x0B, 0x00, 0x00, 0x00} fcMock.StateAtBlockRootVal[common.HexToHash("0xbbbb")] = newBidParentState(&beaconCfg, testDependentRoot) + fcMock.Headers[common.HexToHash("0xbbbb")] = &cltypes.BeaconBlockHeader{Slot: 99} + fcMock.ExecutionPayloadGasLimitMap[common.HexToHash("0xaaaa")] = 30_000_000 fcMock.Ancestors[63] = forkchoice.ForkChoiceNode{Root: testDependentRoot, PayloadStatus: cltypes.PayloadStatusPending} + headRoot := common.HexToHash("0xeeee") + headBlock := cltypes.NewBeaconBlock(&beaconCfg, clparams.GloasVersion) + headBlock.ParentRoot = common.HexToHash("0xbbbb") + headBlock.Body.SignedExecutionPayloadBid = &cltypes.SignedExecutionPayloadBid{Message: &cltypes.ExecutionPayloadBid{ + ParentBlockHash: common.HexToHash("0xaaaa"), + BlockHash: common.HexToHash("0xdddd"), + }} + fcMock.HeadVal = headRoot + fcMock.Headers[headRoot] = &cltypes.BeaconBlockHeader{ParentRoot: headBlock.ParentRoot, Slot: 99} + fcMock.Blocks[headRoot] = &cltypes.SignedBeaconBlock{Block: headBlock} prevBlsVerify := blsVerify blsVerify = func(_ []byte, _ []byte, _ []byte) (bool, error) { return true, nil } t.Cleanup(func() { blsVerify = prevBlsVerify }) - seenCache, err := lru.New[seenBidKey, struct{}]("seen_bids_test", seenBidCacheSize) - require.NoError(t, err) validationStateCache := lru.NewWithTTL[bidValidationStateKey, *bidValidationStateEntry]( "bid_validation_states_test", bidValidationStateCacheSize, @@ -55,13 +71,16 @@ func setupExecutionPayloadBidService(t *testing.T, ctrl *gomock.Controller) ( ) service := &executionPayloadBidService{ - syncedDataManager: mockSyncedData, - forkchoiceStore: fcMock, - ethClock: ethClockMock, - beaconCfg: &beaconCfg, - epbsPool: epbsPool, - emitters: beaconevents.NewEventEmitter(), - seenCache: seenCache, + syncedDataManager: mockSyncedData, + forkchoiceStore: fcMock, + ethClock: ethClockMock, + beaconCfg: &beaconCfg, + epbsPool: epbsPool, + emitters: beaconevents.NewEventEmitter(), + now: func() time.Time { + return ethClockMock.GetSlotTime(ethClockMock.GetCurrentSlot()) + }, + seenCache: newSeenBidStore(), validationStateCache: validationStateCache, pendingCond: sync.NewCond(&sync.Mutex{}), } @@ -165,6 +184,98 @@ func TestExecutionPayloadBidServiceNilMessage(t *testing.T) { require.Contains(t, err.Error(), "nil execution payload bid message") } +func TestExecutionPayloadBidServiceRejectsUnrepresentableSlotBeforeDependencies(t *testing.T) { + for _, test := range []struct { + name string + slot uint64 + secondsPerSlot uint64 + }{ + {name: "slot addition overflow", slot: math.MaxUint64, secondsPerSlot: 12}, + {name: "unix second overflow", slot: math.MaxInt64/12 + 1, secondsPerSlot: 12}, + {name: "zero seconds per slot", slot: 100, secondsPerSlot: 0}, + } { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + clock := eth_clock.NewMockEthereumClock(ctrl) + clock.EXPECT().GenesisTime().Return(uint64(0)).AnyTimes() + cfg := clparams.MainnetBeaconConfig + cfg.SecondsPerSlot = test.secondsPerSlot + stateCalls := 0 + fc := forkchoice_mock.NewForkChoiceStorageMock(t) + fc.GetStateAtBlockRootFn = func(common.Hash, bool) (*state2.CachingBeaconState, error) { + stateCalls++ + return nil, nil + } + service := &executionPayloadBidService{ + forkchoiceStore: fc, + ethClock: clock, + beaconCfg: &cfg, + epbsPool: pool.NewEpbsPool(), + now: func() time.Time { return time.Unix(0, 0) }, + seenCache: newSeenBidStore(), + } + + err := service.ProcessMessage(context.Background(), nil, newTestSignedExecutionPayloadBid(test.slot, 1, 1)) + require.ErrorIs(t, err, ErrIgnore) + require.Zero(t, stateCalls) + }) + } +} + +func TestSafeSlotTimeRepresentabilityBoundaries(t *testing.T) { + ctrl := gomock.NewController(t) + clock := eth_clock.NewMockEthereumClock(ctrl) + clock.EXPECT().GenesisTime().Return(uint64(7)).AnyTimes() + cfg := clparams.MainnetBeaconConfig + cfg.SecondsPerSlot = 12 + lastSlot := uint64((math.MaxInt64 - 7) / 12) + wantUnixSeconds := uint64(7) + lastSlot*cfg.SecondsPerSlot + + got, ok := safeSlotTime(clock, &cfg, lastSlot) + require.True(t, ok) + require.Equal(t, time.Unix(int64(wantUnixSeconds), 0), got) + _, ok = safeSlotTime(clock, &cfg, lastSlot+1) + require.False(t, ok) + _, ok = safeSlotTime(clock, &cfg, math.MaxUint64) + require.False(t, ok) + + cfg.SecondsPerSlot = 0 + _, ok = safeSlotTime(clock, &cfg, 1) + require.False(t, ok) +} + +func TestExecutionPayloadBidServiceOrdersHighestBeforeStatelessChecks(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, ethClockMock, _, epbsPool := setupExecutionPayloadBidService(t, ctrl) + msg := newTestSignedExecutionPayloadBid(100, 1, 1) + msg.Message.ExecutionPayment = 1 + epbsPool.HighestBids.Add(pool.HighestBidKey{ + Slot: msg.Message.Slot, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot, + }, newTestSignedExecutionPayloadBid(100, 2, 2)) + ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) + + err := service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + require.Contains(t, err.Error(), "not higher") +} + +func TestExecutionPayloadBidServiceAuthenticatesAcceptedBidOnce(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl) + msg := newTestSignedExecutionPayloadBid(100, 1, 1) + addPreferencesToPool(epbsPool, 100) + fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated + ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) + var calls int + blsVerify = func(_, _, _ []byte) (bool, error) { + calls++ + return true, nil + } + + require.NoError(t, service.ProcessMessage(context.Background(), nil, msg)) + require.Equal(t, 1, calls) +} + func TestExecutionPayloadBidServiceWrongSlot(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -181,6 +292,31 @@ func TestExecutionPayloadBidServiceWrongSlot(t *testing.T) { require.Contains(t, err.Error(), "not current") } +func TestIsCurrentOrNextSlotClockDisparityBoundaries(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl) + lower := service.ethClock.GetSlotTime(99).Add(-gloasMaximumClockDisparity) + upper := service.ethClock.GetSlotTime(101).Add(gloasMaximumClockDisparity) + + tests := []struct { + name string + now time.Time + want bool + }{ + {name: "before lower", now: lower.Add(-time.Millisecond), want: false}, + {name: "at lower", now: lower, want: true}, + {name: "after lower", now: lower.Add(time.Millisecond), want: true}, + {name: "before upper", now: upper.Add(-time.Millisecond), want: true}, + {name: "at upper", now: upper, want: true}, + {name: "after upper", now: upper.Add(time.Millisecond), want: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, isCurrentOrNextSlot(service.ethClock, service.beaconCfg, test.now, 100, gloasMaximumClockDisparity)) + }) + } +} + func TestExecutionPayloadBidServiceCurrentSlot(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -241,7 +377,7 @@ func TestExecutionPayloadBidServiceNextSlot(t *testing.T) { err := service.ProcessMessage(context.Background(), nil, msg) require.NoError(t, err) - require.Equal(t, uint64(100), parentState.Slot()) + require.Equal(t, uint64(99), parentState.Slot()) } func TestExecutionPayloadBidServiceNoPreferences(t *testing.T) { @@ -262,15 +398,17 @@ func TestExecutionPayloadBidServiceNoPreferences(t *testing.T) { bidKey := pool.HighestBidKey{Slot: 100, ParentBlockHash: common.HexToHash("0xaaaa"), ParentBlockRoot: common.HexToHash("0xbbbb")} _, found := epbsPool.HighestBids.Get(bidKey) require.False(t, found) + require.Zero(t, service.validationStateCache.Len()) } -func TestExecutionPayloadBidServiceRejectsNonZeroExecutionPaymentBeforeQueue(t *testing.T) { +func TestExecutionPayloadBidServiceRejectsNonZeroExecutionPaymentWithMissingStateBeforeQueue(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, _, ethClockMock, _, _ := setupExecutionPayloadBidService(t, ctrl) + service, _, ethClockMock, fcMock, _ := setupExecutionPayloadBidService(t, ctrl) msg := newTestSignedExecutionPayloadBid(100, 1, 1000) msg.Message.ExecutionPayment = 1 + delete(fcMock.StateAtBlockRootVal, msg.Message.ParentBlockRoot) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) @@ -281,12 +419,13 @@ func TestExecutionPayloadBidServiceRejectsNonZeroExecutionPaymentBeforeQueue(t * require.Equal(t, int32(0), service.pendingCount.Load()) } -func TestExecutionPayloadBidServiceRejectsTooManyBlobCommitmentsBeforeQueue(t *testing.T) { +func TestExecutionPayloadBidServiceRejectsTooManyBlobCommitmentsWithMissingStateBeforeQueue(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, _, ethClockMock, _, _ := setupExecutionPayloadBidService(t, ctrl) + service, _, ethClockMock, fcMock, _ := setupExecutionPayloadBidService(t, ctrl) msg := newTestSignedExecutionPayloadBid(100, 1, 1000) + delete(fcMock.StateAtBlockRootVal, msg.Message.ParentBlockRoot) maxBlobs := int(service.beaconCfg.GetBlobParameters(100 / service.beaconCfg.SlotsPerEpoch).MaxBlobsPerBlock) for i := 0; i <= maxBlobs; i++ { msg.Message.BlobKzgCommitments.Append(&cltypes.KZGCommitment{}) @@ -319,21 +458,23 @@ func TestExecutionPayloadBidServiceWaitsForMatchingDependentRootPreference(t *te }) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) - require.True(t, errors.Is(service.ProcessMessage(context.Background(), nil, msg), ErrIgnore)) - require.Equal(t, int32(1), service.pendingCount.Load()) + err := service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + require.NotErrorIs(t, err, ErrBidQueued) + require.Equal(t, int32(0), service.pendingCount.Load()) addPreferencesToPool(epbsPool, 100) fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{} ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) - service.processPendingBids() + require.NoError(t, service.ProcessMessage(context.Background(), nil, msg)) require.Equal(t, int32(0), service.pendingCount.Load()) _, found := epbsPool.HighestBids.Get(pool.HighestBidKey{Slot: 100, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot}) require.True(t, found) } -func TestExecutionPayloadBidServiceWaitsForParentState(t *testing.T) { +func TestExecutionPayloadBidServiceMissingParentStateIsNotQueued(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -341,25 +482,16 @@ func TestExecutionPayloadBidServiceWaitsForParentState(t *testing.T) { msg := newTestSignedExecutionPayloadBid(100, 1, 1000) addPreferencesToPool(epbsPool, 100) fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{} + fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated delete(fcMock.StateAtBlockRootVal, msg.Message.ParentBlockRoot) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) - require.True(t, errors.Is(service.ProcessMessage(context.Background(), nil, msg), ErrIgnore)) - require.Equal(t, int32(1), service.pendingCount.Load()) - - ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) - service.processPendingBids() - require.Equal(t, int32(1), service.pendingCount.Load()) - - fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot] = newBidParentState(service.beaconCfg, testDependentRoot) - fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated - fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{} - ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) - - service.processPendingBids() - require.Equal(t, int32(0), service.pendingCount.Load()) + err := service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + require.NotErrorIs(t, err, ErrBidQueued) + require.Zero(t, service.pendingCount.Load()) _, found := epbsPool.HighestBids.Get(pool.HighestBidKey{Slot: 100, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot}) - require.True(t, found) + require.False(t, found) } func TestExecutionPayloadBidServiceUsesDependentRootFromForkchoiceStore(t *testing.T) { @@ -380,25 +512,89 @@ func TestExecutionPayloadBidServiceUsesDependentRootFromForkchoiceStore(t *testi err := service.ProcessMessage(context.Background(), nil, msg) require.NoError(t, err) - require.Equal(t, uint64(100), parentState.Slot()) + require.Equal(t, uint64(99), parentState.Slot()) _, found := epbsPool.HighestBids.Get(pool.HighestBidKey{Slot: 100, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot}) require.True(t, found) } -func TestExecutionPayloadBidServiceRejectsEarlyEpochDependentRootLookup(t *testing.T) { +func TestExecutionPayloadBidServiceUsesGenesisDependentRootInEarlyEpoch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, _, ethClockMock, fcMock, _ := setupExecutionPayloadBidService(t, ctrl) + service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl) msg := newTestSignedExecutionPayloadBid(1, 1, 1000) + genesisRoot := common.HexToHash("0x1234") + fcMock.Ancestors[0] = forkchoice.ForkChoiceNode{Root: genesisRoot} fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{} + fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot] = newBidParentState(service.beaconCfg, genesisRoot) + fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot].SetSlot(0) + fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot].GetBuilders().Get(1).DepositEpoch = 0 + fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated + addPreferencesToPoolWithRoot(epbsPool, 1, genesisRoot) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(1)) err := service.ProcessMessage(context.Background(), nil, msg) - require.Error(t, err) - require.True(t, errors.Is(err, ErrIgnore)) - require.Contains(t, err.Error(), "cannot compute proposer dependent root") + require.NoError(t, err) +} + +func TestBidCompatibleWithHead(t *testing.T) { + headRoot := common.HexToHash("0x10") + parentRoot := common.HexToHash("0x20") + parentPayload := common.HexToHash("0x30") + headPayload := common.HexToHash("0x40") + headHeader := &cltypes.BeaconBlockHeader{ParentRoot: parentRoot, Slot: 99} + headBid := &cltypes.ExecutionPayloadBid{ParentBlockHash: parentPayload, BlockHash: headPayload} + + buildsOnParent := &cltypes.ExecutionPayloadBid{Slot: 100, ParentBlockRoot: parentRoot, ParentBlockHash: parentPayload} + require.True(t, bidCompatibleWithHead(buildsOnParent, headRoot, headHeader, headBid, true)) + buildsOnHead := &cltypes.ExecutionPayloadBid{Slot: 100, ParentBlockRoot: headRoot, ParentBlockHash: headPayload} + require.True(t, bidCompatibleWithHead(buildsOnHead, headRoot, headHeader, headBid, true)) + require.False(t, bidCompatibleWithHead(buildsOnHead, headRoot, headHeader, headBid, false)) + stale := &cltypes.ExecutionPayloadBid{Slot: 100, ParentBlockRoot: common.HexToHash("0x50"), ParentBlockHash: parentPayload} + require.False(t, bidCompatibleWithHead(stale, headRoot, headHeader, headBid, false)) +} + +func TestExecutionPayloadBidServiceFirstGloasSlotBuildsOnFuluHead(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + service, _, _, fc, _ := setupExecutionPayloadBidService(t, ctrl) + headRoot := common.HexToHash("0xf001") + payloadHash := common.HexToHash("0xf002") + headBlock := cltypes.NewBeaconBlock(service.beaconCfg, clparams.FuluVersion) + headBlock.Body.ExecutionPayload.BlockHash = payloadHash + fc.HeadVal = headRoot + fc.Headers[headRoot] = &cltypes.BeaconBlockHeader{Slot: 99} + fc.Blocks[headRoot] = &cltypes.SignedBeaconBlock{Block: headBlock} + + compatible, err := service.isBidCompatibleWithHead(&cltypes.ExecutionPayloadBid{ + Slot: 100, ParentBlockRoot: headRoot, ParentBlockHash: payloadHash, + }) + require.NoError(t, err) + require.True(t, compatible) +} + +func TestExecutionPayloadBidServiceUsesCoherentHeadNodeSnapshot(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + service, _, _, fc, _ := setupExecutionPayloadBidService(t, ctrl) + headRoot := fc.HeadVal + fc.GetHeadNodeFn = func() (forkchoice.ForkChoiceNode, error) { + fc.HeadVal = common.HexToHash("0xdead") + return forkchoice.ForkChoiceNode{Root: headRoot, PayloadStatus: cltypes.PayloadStatusFull}, nil + } + compatible, err := service.isBidCompatibleWithHead(&cltypes.ExecutionPayloadBid{ + Slot: 100, ParentBlockRoot: headRoot, ParentBlockHash: common.HexToHash("0xdddd"), + }) + require.NoError(t, err) + require.True(t, compatible) +} + +func TestSeenBidKeyIncludesCompatibleParentTuple(t *testing.T) { + bid1 := newTestSignedExecutionPayloadBid(100, 1, 1000).Message + bid2 := newTestSignedExecutionPayloadBid(100, 1, 1001).Message + bid2.ParentBlockRoot = common.HexToHash("0xdddd") + require.NotEqual(t, newSeenBidKey(bid1), newSeenBidKey(bid2)) } func TestExecutionPayloadBidServiceGasLimitIncompatible(t *testing.T) { @@ -424,6 +620,44 @@ func TestExecutionPayloadBidServiceGasLimitIncompatible(t *testing.T) { require.Contains(t, err.Error(), "gas_limit") } +func TestExecutionPayloadBidServiceKnownPayloadWithoutGasLimitIsIgnored(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl) + msg := newTestSignedExecutionPayloadBid(100, 1, 1000) + addPreferencesToPool(epbsPool, 100) + fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated + delete(fcMock.ExecutionPayloadGasLimitMap, msg.Message.ParentBlockHash) + ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) + var blsCalls int + blsVerify = func(_, _, _ []byte) (bool, error) { + blsCalls++ + return true, nil + } + + err := service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + require.Contains(t, err.Error(), "gas limit") + require.Zero(t, blsCalls) + require.Zero(t, service.validationStateCache.Len()) +} + +func TestExecutionPayloadBidServiceHeadUnavailableDoesNotFetchValidationState(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl) + msg := newTestSignedExecutionPayloadBid(100, 1, 1000) + addPreferencesToPool(epbsPool, 100) + fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated + fcMock.GetHeadNodeFn = func() (forkchoice.ForkChoiceNode, error) { + return forkchoice.ForkChoiceNode{}, errors.New("head unavailable") + } + ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) + + err := service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + require.Contains(t, err.Error(), "head unavailable") + require.Zero(t, service.validationStateCache.Len()) +} + func TestExecutionPayloadBidServiceDuplicate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -463,6 +697,7 @@ func TestExecutionPayloadBidServiceBuilderInactiveError(t *testing.T) { parentState.GetBuilders().Get(1).WithdrawableEpoch = 3 fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot] = parentState fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{} + fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated addPreferencesToPool(epbsPool, 100) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) @@ -473,7 +708,7 @@ func TestExecutionPayloadBidServiceBuilderInactiveError(t *testing.T) { require.Contains(t, err.Error(), "not active") // Should NOT be marked as seen - seenKey := seenBidKey{builderIndex: 1, slot: 100} + seenKey := newSeenBidKey(msg.Message) require.False(t, service.seenCache.Contains(seenKey)) } @@ -512,6 +747,8 @@ func TestExecutionPayloadBidServiceParentBlockRootUnknown(t *testing.T) { // parent_block_hash known, but parent_block_root NOT known fcMock.ExecutionPayloadStatusMap[common.HexToHash("0xaaaa")] = execution_client.PayloadStatusValidated // Headers map is empty → parent_block_root not found + delete(fcMock.Headers, msg.Message.ParentBlockRoot) + delete(fcMock.StateAtBlockRootVal, msg.Message.ParentBlockRoot) err := service.ProcessMessage(context.Background(), nil, msg) require.Error(t, err) @@ -574,6 +811,7 @@ func TestExecutionPayloadBidServiceStoreValidBidDoesNotOverwriteHigherBid(t *tes defer ctrl.Finish() service, _, _, _, epbsPool := setupExecutionPayloadBidService(t, ctrl) + service.now = func() time.Time { return time.Unix(100*12, 0) } high := newTestSignedExecutionPayloadBid(100, 1, 2000) low := newTestSignedExecutionPayloadBid(100, 2, 500) @@ -586,7 +824,84 @@ func TestExecutionPayloadBidServiceStoreValidBidDoesNotOverwriteHigherBid(t *tes stored, found := epbsPool.HighestBids.Get(bidKey) require.True(t, found) require.Equal(t, high, stored) - require.False(t, service.seenCache.Contains(seenBidKey{builderIndex: 2, slot: 100})) + require.False(t, service.seenCache.Contains(newSeenBidKey(low.Message))) +} + +func TestExecutionPayloadBidServiceSeenBidsRetainsEveryBidInGossipWindow(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl) + service.now = func() time.Time { return time.Unix(100*12, 0) } + + const builders = 576 + for builderIndex := range uint64(builders) { + bid := newTestSignedExecutionPayloadBid(100, builderIndex, 1000) + bid.Message.ParentBlockHash[0] = byte(builderIndex) + bid.Message.ParentBlockHash[1] = byte(builderIndex >> 8) + bid.Message.ParentBlockRoot[0] = byte(builderIndex) + bid.Message.ParentBlockRoot[1] = byte(builderIndex >> 8) + require.NoError(t, service.storeValidBidAt(bid, service.now())) + } + + first := newTestSignedExecutionPayloadBid(100, 0, 1000) + require.True(t, service.seenCache.Contains(newSeenBidKey(first.Message))) + firstKey := pool.HighestBidKey{Slot: 100, ParentBlockHash: first.Message.ParentBlockHash, ParentBlockRoot: first.Message.ParentBlockRoot} + _, found := service.epbsPool.HighestBids.Get(firstKey) + require.True(t, found) + lower := newTestSignedExecutionPayloadBid(100, builders+1, 999) + err := service.storeValidBidAt(lower, service.now()) + require.ErrorIs(t, err, ErrIgnore) + stored, found := service.epbsPool.HighestBids.Get(firstKey) + require.True(t, found) + require.Equal(t, uint64(1000), stored.Message.Value) +} + +func TestExecutionPayloadBidServiceSeenBidsPrunesAfterGossipWindow(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl) + boundary := time.Unix(101*12, 0).Add(gloasMaximumClockDisparity) + service.now = func() time.Time { return boundary } + stale := newTestSignedExecutionPayloadBid(100, 1, 1000) + require.NoError(t, service.storeValidBidAt(stale, service.now())) + require.True(t, service.seenCache.Contains(newSeenBidKey(stale.Message))) + + service.now = func() time.Time { return boundary.Add(time.Nanosecond) } + trigger := newTestSignedExecutionPayloadBid(101, 2, 1000) + trigger.Message.ParentBlockHash = common.Hash{2} + trigger.Message.ParentBlockRoot = common.Hash{3} + require.NoError(t, service.storeValidBidAt(trigger, service.now())) + require.False(t, service.seenCache.Contains(newSeenBidKey(stale.Message))) + _, found := service.epbsPool.HighestBids.Get(pool.HighestBidKey{ + Slot: stale.Message.Slot, + ParentBlockHash: stale.Message.ParentBlockHash, + ParentBlockRoot: stale.Message.ParentBlockRoot, + }) + require.False(t, found) +} + +func TestExecutionPayloadBidServiceRetainsPreferencesThroughBidWindow(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + service, _, _, fc, epbsPool := setupExecutionPayloadBidService(t, ctrl) + bid := newTestSignedExecutionPayloadBid(100, 1, 1000) + dependentRoot := fc.Ancestors[63].Root + preferences := &cltypes.SignedProposerPreferences{Message: &cltypes.ProposerPreferences{ + ProposalSlot: bid.Message.Slot, + DependentRoot: dependentRoot, + }} + epbsPool.ProposerPreferences.Add(pool.ProposerPreferencesKey{Slot: bid.Message.Slot, DependentRoot: dependentRoot}, preferences) + now := time.Unix(100*12, 0).Add(gloasMaximumClockDisparity + time.Nanosecond) + epbsPool.ProposerPreferences.PruneSlots(func(entrySlot uint64) bool { + return isPastBidWindow(service.ethClock, service.beaconCfg, now, entrySlot) + }) + + matched, ok, err := service.matchingProposerPreferences(bid) + require.NoError(t, err) + require.True(t, ok) + require.Same(t, preferences, matched) } func TestExecutionPayloadBidServiceRejectsLowerBidBeforeStateFetch(t *testing.T) { @@ -611,23 +926,25 @@ func TestExecutionPayloadBidServiceRejectsLowerBidBeforeStateFetch(t *testing.T) require.Equal(t, int32(0), service.pendingCount.Load()) } -func TestExecutionPayloadBidServiceDifferentParentHashes(t *testing.T) { +func TestExecutionPayloadBidServiceSameBuilderDistinctCompatibleParents(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl) - parentHash1 := common.HexToHash("0x1111") - parentHash2 := common.HexToHash("0x2222") - parentRoot := common.HexToHash("0xbbbb") + parentHash1 := common.HexToHash("0xaaaa") + parentHash2 := common.HexToHash("0xdddd") + parentRoot1 := common.HexToHash("0xbbbb") + parentRoot2 := common.HexToHash("0xeeee") fcMock.ExecutionPayloadStatusMap[parentHash1] = execution_client.PayloadStatusValidated fcMock.ExecutionPayloadStatusMap[parentHash2] = execution_client.PayloadStatusValidated - fcMock.Headers[parentRoot] = &cltypes.BeaconBlockHeader{} + fcMock.ExecutionPayloadGasLimitMap[parentHash2] = 30_000_000 + fcMock.Headers[parentRoot1] = &cltypes.BeaconBlockHeader{Slot: 99} + fcMock.StateAtBlockRootVal[parentRoot2] = newBidParentState(service.beaconCfg, testDependentRoot) addPreferencesToPool(epbsPool, 100) - // Bid 1: parentBlockHash = 0x1111, value 1000 msg1 := newTestSignedExecutionPayloadBid(100, 1, 1000) msg1.Message.ParentBlockHash = parentHash1 ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) @@ -635,17 +952,17 @@ func TestExecutionPayloadBidServiceDifferentParentHashes(t *testing.T) { err := service.ProcessMessage(context.Background(), nil, msg1) require.NoError(t, err) - // Bid 2: parentBlockHash = 0x2222, value 500 (separate market → should succeed) - msg2 := newTestSignedExecutionPayloadBid(100, 2, 500) + msg2 := newTestSignedExecutionPayloadBid(100, 1, 500) msg2.Message.ParentBlockHash = parentHash2 + msg2.Message.ParentBlockRoot = parentRoot2 ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) err = service.ProcessMessage(context.Background(), nil, msg2) require.NoError(t, err) // Both should have their own highest bid - bidKey1 := pool.HighestBidKey{Slot: 100, ParentBlockHash: parentHash1, ParentBlockRoot: parentRoot} - bidKey2 := pool.HighestBidKey{Slot: 100, ParentBlockHash: parentHash2, ParentBlockRoot: parentRoot} + bidKey1 := pool.HighestBidKey{Slot: 100, ParentBlockHash: parentHash1, ParentBlockRoot: parentRoot1} + bidKey2 := pool.HighestBidKey{Slot: 100, ParentBlockHash: parentHash2, ParentBlockRoot: parentRoot2} stored1, found1 := epbsPool.HighestBids.Get(bidKey1) stored2, found2 := epbsPool.HighestBids.Get(bidKey2) require.True(t, found1) @@ -672,7 +989,7 @@ func TestExecutionPayloadBidServiceSuccess(t *testing.T) { require.NoError(t, err) // Verify stored in seen cache - seenKey := seenBidKey{builderIndex: 1, slot: 100} + seenKey := newSeenBidKey(msg.Message) require.True(t, service.seenCache.Contains(seenKey)) // Verify stored in pool @@ -693,7 +1010,7 @@ func TestExecutionPayloadBidServicePendingQueueCap(t *testing.T) { msg := newTestSignedExecutionPayloadBid(100, 999, 1000) - service.queuePendingBid(msg) + require.False(t, service.queuePendingBid(msg)) // Should still be at cap — new item was rejected require.Equal(t, int32(maxPendingBids), service.pendingCount.Load()) @@ -702,7 +1019,21 @@ func TestExecutionPayloadBidServicePendingQueueCap(t *testing.T) { require.False(t, exists) } -func TestExecutionPayloadBidServicePendingQueueKeepsDistinctSameBuilderSlot(t *testing.T) { +func TestExecutionPayloadBidServiceDoesNotQueueWhenPreferencesAreMissing(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + service, _, ethClock, _, _ := setupExecutionPayloadBidService(t, ctrl) + msg := newTestSignedExecutionPayloadBid(100, 1, 1000) + ethClock.EXPECT().GetCurrentSlot().Return(uint64(100)) + + err := service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + require.NotErrorIs(t, err, ErrBidQueued) + require.Contains(t, err.Error(), "proposer preferences not available") + require.Zero(t, service.pendingCount.Load()) +} + +func TestExecutionPayloadBidServicePendingQueueKeepsBoundedSignatureVariants(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -711,17 +1042,48 @@ func TestExecutionPayloadBidServicePendingQueueKeepsDistinctSameBuilderSlot(t *t second := newTestSignedExecutionPayloadBid(100, 1, 1000) second.Signature[0] = 1 - service.queuePendingBid(first) - service.queuePendingBid(first) - service.queuePendingBid(second) + require.True(t, service.queuePendingBid(first)) + require.False(t, service.queuePendingBid(first)) + require.True(t, service.queuePendingBid(second)) require.Equal(t, int32(2), service.pendingCount.Load()) stored, firstExists := service.pendingBids.Load(pendingBidKeyFor(first)) require.True(t, firstExists) require.Same(t, first, stored.(*pendingBidJob).msg) - stored, secondExists := service.pendingBids.Load(pendingBidKeyFor(second)) - require.True(t, secondExists) - require.Same(t, second, stored.(*pendingBidJob).msg) + require.NotEqual(t, pendingBidKeyFor(first), pendingBidKeyFor(second)) +} + +func TestExecutionPayloadBidServiceInvalidSignaturesCannotFillPendingTuple(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service, _, ethClock, fc, epbsPool := setupExecutionPayloadBidService(t, ctrl) + valid := newTestSignedExecutionPayloadBid(100, 1, 1000) + blsVerify = func(signature, _, _ []byte) (bool, error) { return signature[0] == 0, nil } + fc.Headers[valid.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{Slot: 99} + fc.ExecutionPayloadStatusMap[valid.Message.ParentBlockHash] = execution_client.PayloadStatusValidated + addPreferencesToPool(epbsPool, 100) + ethClock.EXPECT().GetCurrentSlot().Return(uint64(100)).Times(maxPendingBidsPerTuple + 2) + + for i := range maxPendingBidsPerTuple + 1 { + invalid := newTestSignedExecutionPayloadBid(100, 1, 1000) + invalid.Signature[0] = byte(i + 1) + err := service.ProcessMessage(context.Background(), nil, invalid) + require.ErrorContains(t, err, "invalid builder signature") + require.NotErrorIs(t, err, ErrBidQueued) + } + require.Zero(t, service.pendingCount.Load()) + require.NoError(t, service.ProcessMessage(context.Background(), nil, valid)) + require.Zero(t, service.pendingCount.Load()) + + stored, ok := epbsPool.HighestBids.Get(pool.HighestBidKey{ + Slot: valid.Message.Slot, + ParentBlockHash: valid.Message.ParentBlockHash, + ParentBlockRoot: valid.Message.ParentBlockRoot, + }) + require.True(t, ok) + require.Equal(t, valid.Signature, stored.Signature) + require.Zero(t, service.pendingCount.Load()) } func TestExecutionPayloadBidServiceDeletePendingBidDoesNotRemoveOtherSameBuilderSlot(t *testing.T) { @@ -731,6 +1093,7 @@ func TestExecutionPayloadBidServiceDeletePendingBidDoesNotRemoveOtherSameBuilder service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl) first := newTestSignedExecutionPayloadBid(100, 1, 1000) second := newTestSignedExecutionPayloadBid(100, 1, 2000) + second.Message.ParentBlockRoot = common.HexToHash("0xcccc") service.queuePendingBid(first) firstKey := pendingBidKeyFor(first) @@ -746,6 +1109,21 @@ func TestExecutionPayloadBidServiceDeletePendingBidDoesNotRemoveOtherSameBuilder require.Same(t, second, current.(*pendingBidJob).msg) } +func TestExecutionPayloadBidServiceRejectsNonAdvancingKnownParentBeforeQueue(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + service, _, ethClock, fc, _ := setupExecutionPayloadBidService(t, ctrl) + msg := newTestSignedExecutionPayloadBid(100, 1, 1000) + fc.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{Slot: 100} + fc.StateAtBlockRootVal[msg.Message.ParentBlockRoot].SetSlot(100) + ethClock.EXPECT().GetCurrentSlot().Return(uint64(100)) + + err := service.ProcessMessage(context.Background(), nil, msg) + require.Error(t, err) + require.NotErrorIs(t, err, ErrBidQueued) + require.Zero(t, service.pendingCount.Load()) +} + func TestExecutionPayloadBidServicePendingQueueCapConcurrent(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -834,6 +1212,7 @@ func TestExecutionPayloadBidServiceFeeRecipientMismatch(t *testing.T) { err := service.ProcessMessage(context.Background(), nil, msg) require.Error(t, err) + require.ErrorIs(t, err, ErrIgnore) require.Contains(t, err.Error(), "fee_recipient") require.Contains(t, err.Error(), "does not match") } @@ -869,6 +1248,7 @@ func TestExecutionPayloadBidServiceRejectsPrevRandaoMismatch(t *testing.T) { require.NoError(t, parentState.SetRandaoMixAt(int(state2.Epoch(parentState)%service.beaconCfg.EpochsPerHistoricalVector), common.Hash{0x42})) fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot] = parentState fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{} + fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated addPreferencesToPool(epbsPool, 100) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) @@ -914,7 +1294,7 @@ func TestExecutionPayloadBidServiceFailedValidationNotStored(t *testing.T) { require.Error(t, err) // Should NOT be in seen cache - seenKey := seenBidKey{builderIndex: 1, slot: 100} + seenKey := newSeenBidKey(msg.Message) require.False(t, service.seenCache.Contains(seenKey)) // Should NOT be in pool diff --git a/cl/phase1/network/services/execution_payload_service.go b/cl/phase1/network/services/execution_payload_service.go index e9fa597abd9..086b85a9475 100644 --- a/cl/phase1/network/services/execution_payload_service.go +++ b/cl/phase1/network/services/execution_payload_service.go @@ -170,8 +170,11 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64, // [IGNORE] The envelope is from a slot greater than or equal to the latest finalized slot finalizedSlot := s.forkchoiceStore.FinalizedSlot() - if block.Block.Slot < finalizedSlot { - return fmt.Errorf("%w: envelope slot %d < finalized slot %d", ErrIgnore, block.Block.Slot, finalizedSlot) + if envelope.Payload == nil { + return errors.New("nil execution payload") + } + if envelope.Payload.SlotNumber < finalizedSlot { + return fmt.Errorf("%w: envelope slot %d < finalized slot %d", ErrIgnore, envelope.Payload.SlotNumber, finalizedSlot) } // Process the execution payload through forkchoice diff --git a/cl/phase1/network/services/execution_payload_service_test.go b/cl/phase1/network/services/execution_payload_service_test.go index 1578d4d630d..6a64ffc2326 100644 --- a/cl/phase1/network/services/execution_payload_service_test.go +++ b/cl/phase1/network/services/execution_payload_service_test.go @@ -47,6 +47,7 @@ func newTestSignedEnvelope(slot uint64, blockRoot common.Hash, builderIndex uint envelope.BuilderIndex = builderIndex // Initialize Eth1Block fields needed for HashSSZ if envelope.Payload != nil { + envelope.Payload.SlotNumber = slot envelope.Payload.Extra = solid.NewExtraData() envelope.Payload.Transactions = &solid.TransactionsSSZ{} } @@ -130,7 +131,7 @@ func TestExecutionPayloadServiceSlotBelowFinalized(t *testing.T) { // Add block to forkchoice fcu.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{ Block: &cltypes.BeaconBlock{ - Slot: 50, + Slot: 51, }, } diff --git a/cl/phase1/network/services/proposer_preferences_service.go b/cl/phase1/network/services/proposer_preferences_service.go index d767341b433..fb55e90c4ac 100644 --- a/cl/phase1/network/services/proposer_preferences_service.go +++ b/cl/phase1/network/services/proposer_preferences_service.go @@ -3,13 +3,14 @@ package services import ( "context" "fmt" + "sync" + "time" "github.com/erigontech/erigon/cl/beacon/synced_data" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/gossip" "github.com/erigontech/erigon/cl/phase1/core/state" - "github.com/erigontech/erigon/cl/phase1/core/state/lru" "github.com/erigontech/erigon/cl/phase1/forkchoice" "github.com/erigontech/erigon/cl/pool" "github.com/erigontech/erigon/cl/transition" @@ -20,12 +21,13 @@ import ( ) type seenProposerPreferencesKey struct { - validatorIndex uint64 - slot uint64 - dependentRoot common.Hash + slot uint64 + dependentRoot common.Hash } -const seenProposerPreferencesCacheSize = 128 // ~2 epochs of slots * some buffer +func newSeenProposerPreferencesKey(preferences *cltypes.ProposerPreferences) seenProposerPreferencesKey { + return seenProposerPreferencesKey{slot: preferences.ProposalSlot, dependentRoot: preferences.DependentRoot} +} type proposerPreferencesService struct { syncedDataManager synced_data.SyncedData @@ -33,8 +35,9 @@ type proposerPreferencesService struct { ethClock eth_clock.EthereumClock beaconCfg *clparams.BeaconChainConfig epbsPool *pool.EpbsPool + now func() time.Time - seenCache *lru.Cache[seenProposerPreferencesKey, struct{}] + storeMu sync.Mutex } // NewProposerPreferencesService creates a new proposer preferences gossip service. @@ -46,17 +49,13 @@ func NewProposerPreferencesService( beaconCfg *clparams.BeaconChainConfig, epbsPool *pool.EpbsPool, ) ProposerPreferencesService { - seenCache, err := lru.New[seenProposerPreferencesKey, struct{}]("seen_proposer_preferences", seenProposerPreferencesCacheSize) - if err != nil { - panic(err) - } return &proposerPreferencesService{ syncedDataManager: syncedDataManager, forkchoiceStore: forkchoiceStore, ethClock: ethClock, beaconCfg: beaconCfg, epbsPool: epbsPool, - seenCache: seenCache, + now: time.Now, } } @@ -85,36 +84,58 @@ func (s *proposerPreferencesService) ProcessMessage(ctx context.Context, _ *uint "proposalSlot", proposalSlot, "validatorIndex", validatorIndex) - // [IGNORE] compute_epoch_at_slot(preferences.proposal_slot) in range(current_epoch, current_epoch + MIN_SEED_LOOKAHEAD + 1) - currentEpoch := s.ethClock.GetCurrentEpoch() + now := s.now() + s.epbsPool.ProposerPreferences.PruneSlots(func(entrySlot uint64) bool { + return isPastBidWindow(s.ethClock, s.beaconCfg, now, entrySlot) + }) + past, validTime := isPastSlot(s.ethClock, s.beaconCfg, now, proposalSlot, gloasMaximumClockDisparity) + if !validTime { + return fmt.Errorf("%w: proposal slot %d has no representable time", ErrIgnore, proposalSlot) + } + if past { + return fmt.Errorf("%w: proposal slot %d has already passed", ErrIgnore, proposalSlot) + } + if s.beaconCfg.SlotsPerEpoch == 0 { + return fmt.Errorf("%w: slots per epoch is zero", ErrIgnore) + } proposalEpoch := state.GetEpochAtSlot(s.beaconCfg, proposalSlot) - if proposalEpoch < currentEpoch || proposalEpoch > currentEpoch+s.beaconCfg.MinSeedLookahead { - return fmt.Errorf("%w: proposal slot %d is in epoch %d, expected epoch in [%d, %d]", - ErrIgnore, proposalSlot, proposalEpoch, currentEpoch, currentEpoch+s.beaconCfg.MinSeedLookahead) + if proposalEpoch < s.beaconCfg.MinSeedLookahead { + return fmt.Errorf("%w: proposal epoch %d before min seed lookahead %d", ErrIgnore, proposalEpoch, s.beaconCfg.MinSeedLookahead) } - - // [IGNORE] The proposal slot has not already passed (proposal_slot > current_slot) - currentSlot := s.ethClock.GetCurrentSlot() - if proposalSlot <= currentSlot { - return fmt.Errorf("%w: proposal slot %d has already passed (current slot %d)", - ErrIgnore, proposalSlot, currentSlot) + lookaheadEpoch := proposalEpoch - s.beaconCfg.MinSeedLookahead + lookaheadEpochStartSlot, ok := safeMultiplyUint64(lookaheadEpoch, s.beaconCfg.SlotsPerEpoch) + if !ok { + return fmt.Errorf("%w: proposer lookahead slot is not representable", ErrIgnore) } - - // [IGNORE] First valid message from this (validator_index, proposal_slot, dependent_root) - seenKey := seenProposerPreferencesKey{ - validatorIndex: validatorIndex, - slot: proposalSlot, - dependentRoot: preferences.DependentRoot, + lookaheadEpochStartTime, ok := safeSlotTime(s.ethClock, s.beaconCfg, lookaheadEpochStartSlot) + if !ok { + return fmt.Errorf("%w: proposer lookahead slot %d has no representable time", ErrIgnore, lookaheadEpochStartSlot) } - if s.seenCache.Contains(seenKey) { + if now.Add(gloasMaximumClockDisparity).Before(lookaheadEpochStartTime) { + return fmt.Errorf("%w: proposer for proposal slot %d is not yet known", ErrIgnore, proposalSlot) + } + + // [IGNORE] First valid message for this dependent root and proposal slot. + seenKey := newSeenProposerPreferencesKey(preferences) + if s.hasSeenPreference(seenKey) { return fmt.Errorf("%w: already seen proposer preferences from validator %d for slot %d with dependent root %v", ErrIgnore, validatorIndex, proposalSlot, preferences.DependentRoot) } - - depState, err := s.forkchoiceStore.GetStateAtBlockRoot(preferences.DependentRoot, false) + dependentHeader, ok := s.forkchoiceStore.GetHeader(preferences.DependentRoot) + if !ok { + return fmt.Errorf("%w: dependent block %v has not been seen", ErrIgnore, preferences.DependentRoot) + } + depState, err := s.forkchoiceStore.GetStateAtBlockRoot(preferences.DependentRoot, true) if err != nil || depState == nil { return fmt.Errorf("%w: state for dependent_root %v not available", ErrIgnore, preferences.DependentRoot) } + if dependentHeader.Slot >= lookaheadEpochStartSlot { + return fmt.Errorf("dependent root slot %d is not before proposer lookahead slot %d", dependentHeader.Slot, lookaheadEpochStartSlot) + } + if !s.isValidDependentRoot(preferences.DependentRoot, lookaheadEpochStartSlot) { + return fmt.Errorf("%w: dependent root is not a possible dependent block", ErrIgnore) + } + validationState, err := s.proposerPreferencesValidationState(depState, proposalEpoch) if err != nil { return fmt.Errorf("%w: failed to prepare dependent state: %w", ErrIgnore, err) @@ -123,8 +144,12 @@ func (s *proposerPreferencesService) ProcessMessage(ctx context.Context, _ *uint return fmt.Errorf("proposer preferences validation failed: %w", err) } - // All checks passed — mark as seen and store in pool - s.seenCache.Add(seenKey, struct{}{}) + s.storeMu.Lock() + defer s.storeMu.Unlock() + if s.hasSeenPreference(seenKey) { + return fmt.Errorf("%w: already seen proposer preferences from validator %d for slot %d with dependent root %v", + ErrIgnore, validatorIndex, proposalSlot, preferences.DependentRoot) + } s.epbsPool.ProposerPreferences.Add(pool.ProposerPreferencesKey{ Slot: proposalSlot, DependentRoot: preferences.DependentRoot, @@ -139,23 +164,51 @@ func (s *proposerPreferencesService) ProcessMessage(ctx context.Context, _ *uint return nil } +func (s *proposerPreferencesService) hasSeenPreference(key seenProposerPreferencesKey) bool { + _, ok := s.epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: key.slot, DependentRoot: key.dependentRoot}) + return ok +} + +func isPastSlot(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, now time.Time, slot uint64, disparity time.Duration) (bool, bool) { + slotTime, ok := safeSlotTime(clock, beaconCfg, slot) + if !ok { + return false, false + } + return now.After(slotTime.Add(disparity)), true +} + +func isPastBidWindow(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, now time.Time, slot uint64) bool { + if slot == ^uint64(0) { + return true + } + nextSlotTime, ok := safeSlotTime(clock, beaconCfg, slot+1) + return !ok || now.After(nextSlotTime.Add(gloasMaximumClockDisparity)) +} + +func (s *proposerPreferencesService) isValidDependentRoot(root common.Hash, epochStartSlot uint64) bool { + if s.forkchoiceStore.HasBlockChildAtOrAfter(root, epochStartSlot) { + return true + } + headRoot, _, err := s.forkchoiceStore.GetHead(nil) + return err == nil && root == headRoot +} + func (s *proposerPreferencesService) proposerPreferencesValidationState(depState *state.CachingBeaconState, proposalEpoch uint64) (*state.CachingBeaconState, error) { if proposalEpoch < s.beaconCfg.MinSeedLookahead { return nil, fmt.Errorf("proposal epoch %d before min seed lookahead %d", proposalEpoch, s.beaconCfg.MinSeedLookahead) } dependentEpoch := proposalEpoch - s.beaconCfg.MinSeedLookahead - validationSlot := dependentEpoch * s.beaconCfg.SlotsPerEpoch + validationSlot, ok := safeMultiplyUint64(dependentEpoch, s.beaconCfg.SlotsPerEpoch) + if !ok { + return nil, fmt.Errorf("dependent validation slot is not representable") + } if depState.Slot() >= validationSlot { return depState, nil } - validationState, err := depState.Copy() - if err != nil { - return nil, err - } - if err := transition.DefaultMachine.ProcessSlots(validationState, validationSlot); err != nil { + if err := transition.DefaultMachine.ProcessSlots(depState, validationSlot); err != nil { return nil, err } - return validationState, nil + return depState, nil } func (s *proposerPreferencesService) validateProposerPreferencesWithState(msg *cltypes.SignedProposerPreferences, depState *state.CachingBeaconState) error { diff --git a/cl/phase1/network/services/proposer_preferences_service_test.go b/cl/phase1/network/services/proposer_preferences_service_test.go index 67a64b0e3c9..f28866605b1 100644 --- a/cl/phase1/network/services/proposer_preferences_service_test.go +++ b/cl/phase1/network/services/proposer_preferences_service_test.go @@ -3,7 +3,10 @@ package services import ( "context" "errors" + "math" + "sync" "testing" + "time" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -13,7 +16,6 @@ import ( "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" state2 "github.com/erigontech/erigon/cl/phase1/core/state" - "github.com/erigontech/erigon/cl/phase1/core/state/lru" forkchoice_mock "github.com/erigontech/erigon/cl/phase1/forkchoice/mock_services" "github.com/erigontech/erigon/cl/pool" "github.com/erigontech/erigon/cl/utils/eth_clock" @@ -23,6 +25,10 @@ import ( func setupProposerPreferencesService(t *testing.T, ctrl *gomock.Controller) (*proposerPreferencesService, *synced_data_mock.MockSyncedData, *eth_clock.MockEthereumClock, *pool.EpbsPool, *forkchoice_mock.ForkChoiceStorageMock) { mockSyncedData := synced_data_mock.NewMockSyncedData(ctrl) ethClockMock := eth_clock.NewMockEthereumClock(ctrl) + ethClockMock.EXPECT().GenesisTime().Return(uint64(0)).AnyTimes() + ethClockMock.EXPECT().GetSlotTime(gomock.Any()).DoAndReturn(func(slot uint64) time.Time { + return time.Unix(int64(slot*12), 0) + }).AnyTimes() epbsPool := pool.NewEpbsPool() beaconCfg := clparams.MainnetBeaconConfig beaconCfg.SlotsPerEpoch = 32 @@ -34,20 +40,21 @@ func setupProposerPreferencesService(t *testing.T, ctrl *gomock.Controller) (*pr StateAtBlockRootVal: map[common.Hash]*state2.CachingBeaconState{}, } forkChoiceMock.StateAtBlockRootVal[testDependentRoot] = newProposerPreferencesState(&beaconCfg, map[uint64]uint64{96: 42, 100: 42}) + forkChoiceMock.Headers[testDependentRoot] = &cltypes.BeaconBlockHeader{Slot: 63} + forkChoiceMock.HeadVal = testDependentRoot prevBlsVerify := blsVerify blsVerify = func(_ []byte, _ []byte, _ []byte) (bool, error) { return true, nil } t.Cleanup(func() { blsVerify = prevBlsVerify }) - seenCache, err := lru.New[seenProposerPreferencesKey, struct{}]("seen_proposer_preferences_test", seenProposerPreferencesCacheSize) - require.NoError(t, err) - service := &proposerPreferencesService{ syncedDataManager: mockSyncedData, forkchoiceStore: forkChoiceMock, ethClock: ethClockMock, beaconCfg: &beaconCfg, epbsPool: epbsPool, - seenCache: seenCache, + now: func() time.Time { + return ethClockMock.GetSlotTime(ethClockMock.GetCurrentSlot()) + }, } return service, mockSyncedData, ethClockMock, epbsPool, forkChoiceMock @@ -58,6 +65,7 @@ var testDependentRoot = common.HexToHash("0xabcdef0123456789abcdef0123456789abcd func newProposerPreferencesState(cfg *clparams.BeaconChainConfig, proposers map[uint64]uint64) *state2.CachingBeaconState { s := state2.New(cfg) + s.SetVersion(clparams.GloasVersion) if err := s.SetSlot(64); err != nil { panic(err) } @@ -120,22 +128,58 @@ func TestProposerPreferencesServiceNilMessage(t *testing.T) { require.Contains(t, err.Error(), "nil proposer preferences message") } -func TestProposerPreferencesServiceWrongEpoch(t *testing.T) { +func TestProposerPreferencesServiceRejectsUnrepresentableSlotBeforeState(t *testing.T) { + for _, test := range []struct { + name string + proposalSlot uint64 + secondsPerSlot uint64 + }{ + {name: "slot maximum", proposalSlot: math.MaxUint64, secondsPerSlot: 12}, + {name: "unix second overflow", proposalSlot: math.MaxInt64/12 + 1, secondsPerSlot: 12}, + {name: "zero seconds per slot", proposalSlot: 100, secondsPerSlot: 0}, + } { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + clock := eth_clock.NewMockEthereumClock(ctrl) + clock.EXPECT().GenesisTime().Return(uint64(0)).AnyTimes() + cfg := clparams.MainnetBeaconConfig + cfg.SecondsPerSlot = test.secondsPerSlot + cfg.SlotsPerEpoch = 32 + cfg.MinSeedLookahead = 1 + stateCalls := 0 + fc := forkchoice_mock.NewForkChoiceStorageMock(t) + fc.GetStateAtBlockRootFn = func(common.Hash, bool) (*state2.CachingBeaconState, error) { + stateCalls++ + return nil, nil + } + service := &proposerPreferencesService{ + forkchoiceStore: fc, + ethClock: clock, + beaconCfg: &cfg, + epbsPool: pool.NewEpbsPool(), + now: func() time.Time { return time.Unix(0, 0) }, + } + + err := service.ProcessMessage(context.Background(), nil, newTestSignedProposerPreferences(test.proposalSlot, 42)) + require.ErrorIs(t, err, ErrIgnore) + require.Zero(t, stateCalls) + }) + } +} + +func TestProposerPreferencesServiceLookaheadNotKnown(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() service, _, ethClockMock, _, _ := setupProposerPreferencesService(t, ctrl) - // proposal_slot=100, SlotsPerEpoch=32 → proposalEpoch = 100/32 = 3 - // currentEpoch = 5 → neither current (5) nor next (6) match proposalEpoch (3) msg := newTestSignedProposerPreferences(100, 42) - - ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(5)) + ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(50)) err := service.ProcessMessage(context.Background(), nil, msg) require.Error(t, err) require.True(t, errors.Is(err, ErrIgnore)) - require.Contains(t, err.Error(), "expected epoch in") + require.Contains(t, err.Error(), "not yet known") } func TestProposerPreferencesServiceCurrentEpoch(t *testing.T) { @@ -147,7 +191,6 @@ func TestProposerPreferencesServiceCurrentEpoch(t *testing.T) { // proposalEpoch == currentEpoch (same epoch) → should be accepted now msg := newTestSignedProposerPreferences(100, 42) - ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(3)) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90)) // slot not yet passed err := service.ProcessMessage(context.Background(), nil, msg) @@ -168,7 +211,6 @@ func TestProposerPreferencesServiceSlotAlreadyPassed(t *testing.T) { // proposalSlot=100, currentSlot=105 → slot already passed → IGNORE msg := newTestSignedProposerPreferences(100, 42) - ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2)) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(105)) err := service.ProcessMessage(context.Background(), nil, msg) @@ -177,22 +219,68 @@ func TestProposerPreferencesServiceSlotAlreadyPassed(t *testing.T) { require.Contains(t, err.Error(), "already passed") } -func TestProposerPreferencesServiceCurrentSlotIgnored(t *testing.T) { +func TestProposerPreferencesServiceCurrentSlotWithinDisparityAccepted(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() service, _, ethClockMock, _, _ := setupProposerPreferencesService(t, ctrl) - // proposalSlot == currentSlot → spec says proposal_slot > current_slot, so this should be IGNORED msg := newTestSignedProposerPreferences(100, 42) - ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2)) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) err := service.ProcessMessage(context.Background(), nil, msg) - require.Error(t, err) - require.True(t, errors.Is(err, ErrIgnore)) - require.Contains(t, err.Error(), "already passed") + require.NoError(t, err) +} + +func TestIsPastSlotClockDisparityBoundaries(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, _, _, _ := setupProposerPreferencesService(t, ctrl) + start := service.ethClock.GetSlotTime(100) + + past, valid := isPastSlot(service.ethClock, service.beaconCfg, start.Add(gloasMaximumClockDisparity-time.Millisecond), 100, gloasMaximumClockDisparity) + require.True(t, valid) + require.False(t, past) + past, valid = isPastSlot(service.ethClock, service.beaconCfg, start.Add(gloasMaximumClockDisparity), 100, gloasMaximumClockDisparity) + require.True(t, valid) + require.False(t, past) + past, valid = isPastSlot(service.ethClock, service.beaconCfg, start.Add(gloasMaximumClockDisparity+time.Millisecond), 100, gloasMaximumClockDisparity) + require.True(t, valid) + require.True(t, past) +} + +func TestProposerPreferencesServiceAcceptsEpochRolloverDisparityEdge(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, _, epbsPool, _ := setupProposerPreferencesService(t, ctrl) + msg := newTestSignedProposerPreferences(96, 42) + service.now = func() time.Time { + return service.ethClock.GetSlotTime(96).Add(gloasMaximumClockDisparity) + } + + require.NoError(t, service.ProcessMessage(context.Background(), nil, msg)) + stored, ok := epbsPool.GetPreference(96, testDependentRoot) + require.True(t, ok) + require.Same(t, msg, stored) +} + +func TestProposerPreferencesServiceLookaheadClockDisparityBoundary(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, _, epbsPool, _ := setupProposerPreferencesService(t, ctrl) + msg := newTestSignedProposerPreferences(100, 42) + lookaheadStart := service.ethClock.GetSlotTime(64) + now := lookaheadStart.Add(-gloasMaximumClockDisparity - time.Millisecond) + service.now = func() time.Time { return now } + + err := service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + require.Contains(t, err.Error(), "not yet known") + require.False(t, service.hasSeenPreference(newSeenProposerPreferencesKey(msg.Message))) + + now = lookaheadStart.Add(-gloasMaximumClockDisparity) + require.NoError(t, service.ProcessMessage(context.Background(), nil, msg)) + stored, ok := epbsPool.GetPreference(100, testDependentRoot) + require.True(t, ok) + require.Same(t, msg, stored) } func TestProposerPreferencesServiceDuplicate(t *testing.T) { @@ -204,14 +292,12 @@ func TestProposerPreferencesServiceDuplicate(t *testing.T) { msg := newTestSignedProposerPreferences(100, 42) // First call: epoch OK and slot not passed. - ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2)) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90)) err := service.ProcessMessage(context.Background(), nil, msg) require.NoError(t, err) // Second call: same (validatorIndex, slot, dependentRoot) → IGNORE - ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2)) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90)) err = service.ProcessMessage(context.Background(), nil, msg) @@ -220,6 +306,52 @@ func TestProposerPreferencesServiceDuplicate(t *testing.T) { require.Contains(t, err.Error(), "already seen proposer preferences") } +func TestProposerPreferencesServiceDuplicateRetainedBeyondFormerCapacity(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + service, _, ethClock, epbsPool, _ := setupProposerPreferencesService(t, ctrl) + msg := newTestSignedProposerPreferences(100, 42) + for i := range uint64(256) { + root := common.Hash{byte(i), byte(i >> 8)} + epbsPool.ProposerPreferences.Add(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: root}, newTestSignedProposerPreferences(100, 42)) + } + epbsPool.ProposerPreferences.Add(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: msg.Message.DependentRoot}, msg) + ethClock.EXPECT().GetCurrentSlot().Return(uint64(100)) + + err := service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + require.Contains(t, err.Error(), "already seen proposer preferences") +} + +func TestProposerPreferencesServicePrunesSeenMarkerAfterBoundary(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + service, _, _, epbsPool, _ := setupProposerPreferencesService(t, ctrl) + msg := newTestSignedProposerPreferences(100, 42) + key := pool.ProposerPreferencesKey{Slot: 100, DependentRoot: msg.Message.DependentRoot} + epbsPool.ProposerPreferences.Add(key, msg) + preferenceBoundary := time.Unix(100*12, 0).Add(gloasMaximumClockDisparity) + service.now = func() time.Time { return preferenceBoundary.Add(time.Nanosecond) } + + err := service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + _, found := epbsPool.ProposerPreferences.Get(key) + require.True(t, found) + + bidBoundary := time.Unix(101*12, 0).Add(gloasMaximumClockDisparity) + service.now = func() time.Time { return bidBoundary } + err = service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + _, found = epbsPool.ProposerPreferences.Get(key) + require.True(t, found) + + service.now = func() time.Time { return bidBoundary.Add(time.Nanosecond) } + err = service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + _, found = epbsPool.ProposerPreferences.Get(key) + require.False(t, found) +} + func TestProposerPreferencesServiceDependentRootStateMissing(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -229,7 +361,6 @@ func TestProposerPreferencesServiceDependentRootStateMissing(t *testing.T) { msg := newTestSignedProposerPreferences(100, 42) delete(forkChoiceMock.StateAtBlockRootVal, testDependentRoot) - ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2)) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90)) err := service.ProcessMessage(context.Background(), nil, msg) @@ -238,8 +369,81 @@ func TestProposerPreferencesServiceDependentRootStateMissing(t *testing.T) { require.Contains(t, err.Error(), "state for dependent_root") // Should NOT be marked as seen (validation failed) - seenKey := seenProposerPreferencesKey{validatorIndex: 42, slot: 100, dependentRoot: testDependentRoot} - require.False(t, service.seenCache.Contains(seenKey)) + seenKey := newSeenProposerPreferencesKey(msg.Message) + require.False(t, service.hasSeenPreference(seenKey)) +} + +func TestProposerPreferencesServiceMissingStatePrecedesBoundaryReject(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + service, _, ethClock, _, fc := setupProposerPreferencesService(t, ctrl) + msg := newTestSignedProposerPreferences(100, 42) + fc.Headers[testDependentRoot] = &cltypes.BeaconBlockHeader{Slot: 64} + delete(fc.StateAtBlockRootVal, testDependentRoot) + ethClock.EXPECT().GetCurrentSlot().Return(uint64(90)) + + err := service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + require.Contains(t, err.Error(), "state for dependent_root") +} + +func TestProposerPreferencesDependentRootValidity(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + service, _, _, _, fc := setupProposerPreferencesService(t, ctrl) + root := testDependentRoot + boundary := uint64(64) + fc.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 63} + + fc.HeadVal = root + require.True(t, service.isValidDependentRoot(root, boundary)) + fc.HeadVal = common.HexToHash("0x99") + childRoot := common.HexToHash("0x88") + fc.Headers[childRoot] = &cltypes.BeaconBlockHeader{Slot: boundary, ParentRoot: root} + require.True(t, service.isValidDependentRoot(root, boundary)) + delete(fc.Headers, childRoot) + fc.WeightsMock = nil + require.False(t, service.isValidDependentRoot(root, boundary)) + fc.Headers[root] = &cltypes.BeaconBlockHeader{Slot: boundary} + require.False(t, service.isValidDependentRoot(root, boundary)) +} + +func TestProposerPreferencesServiceConcurrentFirstValidCommit(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + service, _, ethClock, epbsPool, _ := setupProposerPreferencesService(t, ctrl) + msg := newTestSignedProposerPreferences(100, 42) + ethClock.EXPECT().GetCurrentSlot().Return(uint64(90)).Times(2) + + errs := make(chan error, 2) + var wg sync.WaitGroup + for range 2 { + wg.Go(func() { errs <- service.ProcessMessage(context.Background(), nil, msg) }) + } + wg.Wait() + close(errs) + successes := 0 + ignores := 0 + for err := range errs { + if err == nil { + successes++ + } else if errors.Is(err, ErrIgnore) { + ignores++ + } + } + require.Equal(t, 1, successes) + require.Equal(t, 1, ignores) + stored, ok := epbsPool.GetPreference(100, testDependentRoot) + require.True(t, ok) + require.Same(t, msg, stored) +} + +func TestSeenProposerPreferencesKeyUsesRootAndSlot(t *testing.T) { + root := common.HexToHash("0x11") + require.Equal(t, + seenProposerPreferencesKey{slot: 100, dependentRoot: root}, + newSeenProposerPreferencesKey(&cltypes.ProposerPreferences{ProposalSlot: 100, ValidatorIndex: 42, DependentRoot: root}), + ) } func TestProposerPreferencesServiceSuccess(t *testing.T) { @@ -250,15 +454,14 @@ func TestProposerPreferencesServiceSuccess(t *testing.T) { msg := newTestSignedProposerPreferences(100, 42) - ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2)) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90)) err := service.ProcessMessage(context.Background(), nil, msg) require.NoError(t, err) // Verify stored in seen cache - seenKey := seenProposerPreferencesKey{validatorIndex: 42, slot: 100, dependentRoot: testDependentRoot} - require.True(t, service.seenCache.Contains(seenKey)) + seenKey := newSeenProposerPreferencesKey(msg.Message) + require.True(t, service.hasSeenPreference(seenKey)) // Verify stored in pool stored, ok := epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: testDependentRoot}) @@ -266,7 +469,7 @@ func TestProposerPreferencesServiceSuccess(t *testing.T) { require.Equal(t, msg, stored) } -func TestProposerPreferencesServiceAdvancesDependentRootStateToBoundary(t *testing.T) { +func TestProposerPreferencesServiceRequestsIndependentDependentRootState(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -275,21 +478,49 @@ func TestProposerPreferencesServiceAdvancesDependentRootStateToBoundary(t *testi depState := newProposerPreferencesState(service.beaconCfg, map[uint64]uint64{100: 42}) require.NoError(t, depState.SetSlot(63)) forkChoiceMock.StateAtBlockRootVal[testDependentRoot] = depState + var requestedCopy bool + var ownedState *state2.CachingBeaconState + forkChoiceMock.GetStateAtBlockRootFn = func(root common.Hash, alwaysCopy bool) (*state2.CachingBeaconState, error) { + requestedCopy = alwaysCopy + var err error + ownedState, err = forkChoiceMock.StateAtBlockRootVal[root].Copy() + return ownedState, err + } - msg := newTestSignedProposerPreferences(100, 0) + msg := newTestSignedProposerPreferences(100, 42) - ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2)) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90)) err := service.ProcessMessage(context.Background(), nil, msg) require.NoError(t, err) - require.Equal(t, uint64(63), depState.Slot()) + require.True(t, requestedCopy) + require.NotSame(t, depState, ownedState) + require.Equal(t, uint64(64), depState.Slot()) stored, ok := epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: testDependentRoot}) require.True(t, ok) require.Equal(t, msg, stored) } +func TestProposerPreferencesValidationStateUsesOwnedStateWithoutSecondCopy(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service, _, _, _, _ := setupProposerPreferencesService(t, ctrl) + ownedState := state2.New(service.beaconCfg) + ownedState.SetVersion(clparams.DenebVersion) + ownedState.AddValidator(solid.NewValidatorFromParameters(common.Bytes48{1}, common.Hash{}, service.beaconCfg.MaxEffectiveBalance, false, 0, 0, service.beaconCfg.FarFutureEpoch, service.beaconCfg.FarFutureEpoch), service.beaconCfg.MaxEffectiveBalance) + ownedState.SetPreviousEpochParticipationFlags([]cltypes.ParticipationFlags{0}) + ownedState.SetCurrentEpochParticipationFlags([]cltypes.ParticipationFlags{0}) + ownedState.SetInactivityScores([]uint64{0}) + ownedState.SetSlot(63) + + validationState, err := service.proposerPreferencesValidationState(ownedState, 3) + require.NoError(t, err) + require.Same(t, ownedState, validationState) + require.Equal(t, uint64(64), ownedState.Slot()) +} + func TestProposerPreferencesServiceDifferentValidatorsSameSlot(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -300,7 +531,6 @@ func TestProposerPreferencesServiceDifferentValidatorsSameSlot(t *testing.T) { msg2 := newTestSignedProposerPreferences(101, 2) forkChoiceMock.StateAtBlockRootVal[testDependentRoot] = newProposerPreferencesState(service.beaconCfg, map[uint64]uint64{100: 1, 101: 2}) - ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2)).Times(2) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90)).Times(2) err := service.ProcessMessage(context.Background(), nil, msg1) @@ -310,8 +540,8 @@ func TestProposerPreferencesServiceDifferentValidatorsSameSlot(t *testing.T) { require.NoError(t, err) // Both should be seen (different validators and slots) - require.True(t, service.seenCache.Contains(seenProposerPreferencesKey{validatorIndex: 1, slot: 100, dependentRoot: testDependentRoot})) - require.True(t, service.seenCache.Contains(seenProposerPreferencesKey{validatorIndex: 2, slot: 101, dependentRoot: testDependentRoot})) + require.True(t, service.hasSeenPreference(newSeenProposerPreferencesKey(msg1.Message))) + require.True(t, service.hasSeenPreference(newSeenProposerPreferencesKey(msg2.Message))) stored, ok := epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: testDependentRoot}) require.True(t, ok) @@ -330,7 +560,6 @@ func TestProposerPreferencesServiceSameValidatorDifferentSlots(t *testing.T) { msg1 := newTestSignedProposerPreferences(96, 42) // slot 96, epoch 3 msg2 := newTestSignedProposerPreferences(100, 42) // slot 100, epoch 3 - ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2)).Times(2) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90)).Times(2) err := service.ProcessMessage(context.Background(), nil, msg1) @@ -340,8 +569,8 @@ func TestProposerPreferencesServiceSameValidatorDifferentSlots(t *testing.T) { require.NoError(t, err) // Both should be seen (different slots even though same validator) - require.True(t, service.seenCache.Contains(seenProposerPreferencesKey{validatorIndex: 42, slot: 96, dependentRoot: testDependentRoot})) - require.True(t, service.seenCache.Contains(seenProposerPreferencesKey{validatorIndex: 42, slot: 100, dependentRoot: testDependentRoot})) + require.True(t, service.hasSeenPreference(newSeenProposerPreferencesKey(msg1.Message))) + require.True(t, service.hasSeenPreference(newSeenProposerPreferencesKey(msg2.Message))) // Both slots should be in pool _, ok1 := epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: 96, DependentRoot: testDependentRoot}) @@ -390,15 +619,14 @@ func TestProposerPreferencesServiceFailedValidationNotStored(t *testing.T) { forkChoiceMock.StateAtBlockRootVal[testDependentRoot] = newProposerPreferencesState(service.beaconCfg, map[uint64]uint64{100: 7}) - ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2)) ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90)) err := service.ProcessMessage(context.Background(), nil, msg) require.Error(t, err) // Should NOT be in seen cache - seenKey := seenProposerPreferencesKey{validatorIndex: 42, slot: 100, dependentRoot: testDependentRoot} - require.False(t, service.seenCache.Contains(seenKey)) + seenKey := newSeenProposerPreferencesKey(msg.Message) + require.False(t, service.hasSeenPreference(seenKey)) // Should NOT be in pool _, ok := epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: testDependentRoot}) diff --git a/cl/phase1/stages/chain_tip_sync.go b/cl/phase1/stages/chain_tip_sync.go index 6eee814f9c8..6c2b6d24861 100644 --- a/cl/phase1/stages/chain_tip_sync.go +++ b/cl/phase1/stages/chain_tip_sync.go @@ -727,19 +727,24 @@ func drainPendingGloasPayloads(ctx context.Context, cfg *Cfg) { if !validPendingGloasPayload(p) { continue } + beaconRoot := p.Envelope.Message.BeaconBlockRoot + if cfg.forkChoice.IsPayloadVerified(beaconRoot) { + continue + } status, err := retryGloasPayloadWithEL(ctx, cfg, p.Block, p.Envelope) if err != nil { log.Warn("[chainTipSync] pending GLOAS NewPayload failed", "slot", p.Block.Block.Slot, "status", status, "err", err) } - beaconRoot := p.Envelope.Message.BeaconBlockRoot execHash := p.Envelope.Message.Payload.BlockHash + var retained bool + status, retained = cfg.forkChoice.MarkPayloadStatusIfRetained(beaconRoot, execHash, status) + if !retained { + continue + } switch status { - case execution_client.PayloadStatusValidated: - cfg.forkChoice.MarkPayloadVerified(beaconRoot, execHash) case execution_client.PayloadStatusNone, execution_client.PayloadStatusNotValidated: cfg.forkChoice.RequeuePendingELPayload(p) case execution_client.PayloadStatusInvalidated: - cfg.forkChoice.MarkPayloadInvalid(beaconRoot, execHash) log.Warn("[chainTipSync] pending GLOAS payload invalidated by EL", "slot", p.Block.Block.Slot, "blockRoot", beaconRoot) } } @@ -874,20 +879,22 @@ func verifyUnverifiedGloasPayloads(ctx context.Context, cfg *Cfg) { return continueGloasVerificationAfterItemFailure(ctx, &completeBatch) } if isGloasPayloadKnownInvalid(cfg, envelope) { - cfg.forkChoice.MarkPayloadInvalid(item.root, execHash) + cfg.forkChoice.MarkPayloadStatusIfRetained(item.root, execHash, execution_client.PayloadStatusInvalidated) return true } status, err := retryGloasPayloadWithEL(ctx, cfg, item.block, envelope) if err != nil { log.Warn("[chainTipSync] GLOAS verification sweep NewPayload failed", "slot", item.block.Block.Slot, "blockRoot", item.root, "status", status, "err", err) } + var retained bool + status, retained = cfg.forkChoice.MarkPayloadStatusIfRetained(item.root, execHash, status) + if !retained { + return true + } switch status { - case execution_client.PayloadStatusValidated: - cfg.forkChoice.MarkPayloadVerified(item.root, execHash) case execution_client.PayloadStatusNone, execution_client.PayloadStatusNotValidated: cfg.forkChoice.RequeuePendingELPayload(forkchoice.PendingELPayload{Block: item.block, Envelope: envelope}) case execution_client.PayloadStatusInvalidated: - cfg.forkChoice.MarkPayloadInvalid(item.root, execHash) log.Warn("[chainTipSync] GLOAS verification sweep found invalid payload", "slot", item.block.Block.Slot, "blockRoot", item.root) } swept++ @@ -951,11 +958,12 @@ func retryUnverifiedAnchorPayload(ctx context.Context, cfg *Cfg) { log.Warn("[chainTipSync] anchor payload NewPayload retry failed", "anchorRoot", anchorRoot, "status", status, "err", err) } execHash := envelope.Message.Payload.BlockHash - switch status { - case execution_client.PayloadStatusValidated: - cfg.forkChoice.MarkPayloadVerified(anchorRoot, execHash) - case execution_client.PayloadStatusInvalidated: - cfg.forkChoice.MarkPayloadInvalid(anchorRoot, execHash) + var retained bool + status, retained = cfg.forkChoice.MarkPayloadStatusIfRetained(anchorRoot, execHash, status) + if !retained { + return + } + if status == execution_client.PayloadStatusInvalidated { log.Warn("[chainTipSync] anchor payload invalidated by EL", "anchorRoot", anchorRoot) } } diff --git a/cl/phase1/stages/forward_sync.go b/cl/phase1/stages/forward_sync.go index c502a8cdefe..b072b47f4c3 100644 --- a/cl/phase1/stages/forward_sync.go +++ b/cl/phase1/stages/forward_sync.go @@ -445,11 +445,12 @@ func validateAnchorPayloadWithExecutionClient(ctx context.Context, cfg *Cfg, anc if err != nil { log.Warn("[Caplin] Anchor envelope EL validation failed", "anchorRoot", anchorRoot, "status", status, "err", err) } - switch status { - case execution_client.PayloadStatusValidated: - cfg.forkChoice.MarkPayloadVerified(anchorRoot, env.Message.Payload.BlockHash) - case execution_client.PayloadStatusInvalidated: - cfg.forkChoice.MarkPayloadInvalid(anchorRoot, env.Message.Payload.BlockHash) + var retained bool + status, retained = cfg.forkChoice.MarkPayloadStatusIfRetained(anchorRoot, env.Message.Payload.BlockHash, status) + if !retained { + return nil + } + if status == execution_client.PayloadStatusInvalidated { return fmt.Errorf("anchor execution payload invalidated by EL") } return nil diff --git a/cl/pool/epbs_pool.go b/cl/pool/epbs_pool.go index c85a3d24708..2a99292e04f 100644 --- a/cl/pool/epbs_pool.go +++ b/cl/pool/epbs_pool.go @@ -1,17 +1,65 @@ package pool import ( + "sync" + "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/phase1/core/state/lru" "github.com/erigontech/erigon/common" ) const ( - epbsPreferencesPoolSize = 64 // ~2 epochs of slots - epbsHighestBidsPoolSize = 128 // multiple builders × parent hashes × a few slots epbsPayloadAttestationsPoolSize = 512 // one slot's worth of PTC votes ) +type slotMap[K comparable, V any] struct { + mu sync.RWMutex + values map[K]V + slotFor func(K) uint64 +} + +func newSlotMap[K comparable, V any](slotFor func(K) uint64) *slotMap[K, V] { + return &slotMap[K, V]{values: make(map[K]V), slotFor: slotFor} +} + +func (m *slotMap[K, V]) Add(key K, value V) bool { + m.mu.Lock() + defer m.mu.Unlock() + m.values[key] = value + return false +} + +func (m *slotMap[K, V]) Get(key K) (V, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + value, ok := m.values[key] + return value, ok +} + +func (m *slotMap[K, V]) Keys() []K { + m.mu.RLock() + defer m.mu.RUnlock() + keys := make([]K, 0, len(m.values)) + for key := range m.values { + keys = append(keys, key) + } + return keys +} + +func (m *slotMap[K, V]) PruneSlotsBefore(slot uint64) { + m.PruneSlots(func(entrySlot uint64) bool { return entrySlot < slot }) +} + +func (m *slotMap[K, V]) PruneSlots(remove func(uint64) bool) { + m.mu.Lock() + defer m.mu.Unlock() + for key := range m.values { + if remove(m.slotFor(key)) { + delete(m.values, key) + } + } +} + // ProposerPreferencesKey identifies a proposer preferences entry by slot and dependent root. // Different dependent roots (different forks) must not overwrite each other. type ProposerPreferencesKey struct { @@ -39,11 +87,11 @@ type HighestBidKey struct { type EpbsPool struct { // ProposerPreferences stores validated SignedProposerPreferences keyed by (slot, dependent_root). // Written by the proposer_preferences gossip service, read by the execution_payload_bid service. - ProposerPreferences *lru.Cache[ProposerPreferencesKey, *cltypes.SignedProposerPreferences] + ProposerPreferences *slotMap[ProposerPreferencesKey, *cltypes.SignedProposerPreferences] // HighestBids stores the highest bid seen per (slot, parent_block_hash, parent_block_root). // Written and read by the execution_payload_bid gossip service. - HighestBids *lru.Cache[HighestBidKey, *cltypes.SignedExecutionPayloadBid] + HighestBids *slotMap[HighestBidKey, *cltypes.SignedExecutionPayloadBid] // PayloadAttestations stores recently validated PayloadAttestationMessages for beacon API serving. // Short-lived cache (~1 slot), keyed by (slot, validatorIndex). @@ -51,14 +99,8 @@ type EpbsPool struct { } func NewEpbsPool() *EpbsPool { - preferencesCache, err := lru.New[ProposerPreferencesKey, *cltypes.SignedProposerPreferences]("proposerPreferencesPool", epbsPreferencesPoolSize) - if err != nil { - panic(err) - } - highestBidsCache, err := lru.New[HighestBidKey, *cltypes.SignedExecutionPayloadBid]("highestBidsPool", epbsHighestBidsPoolSize) - if err != nil { - panic(err) - } + preferencesCache := newSlotMap[ProposerPreferencesKey, *cltypes.SignedProposerPreferences](func(key ProposerPreferencesKey) uint64 { return key.Slot }) + highestBidsCache := newSlotMap[HighestBidKey, *cltypes.SignedExecutionPayloadBid](func(key HighestBidKey) uint64 { return key.Slot }) payloadAttestationsCache, err := lru.New[PayloadAttestationKey, *cltypes.PayloadAttestationMessage]("payloadAttestationsPool", epbsPayloadAttestationsPoolSize) if err != nil { panic(err) diff --git a/cl/pool/operations_pool_test.go b/cl/pool/operations_pool_test.go index 0ec08e5eb89..87e6175c1da 100644 --- a/cl/pool/operations_pool_test.go +++ b/cl/pool/operations_pool_test.go @@ -415,3 +415,37 @@ func TestEpbsPoolGetPreferenceExactLookup(t *testing.T) { _, ok = p.GetPreference(slot, otherRoot) require.False(t, ok) } + +func TestEpbsPoolRetainsLiveEntriesBeyondFormerCapacity(t *testing.T) { + p := NewEpbsPool() + for i := range uint64(256) { + root := common.Hash{byte(i), byte(i >> 8)} + p.ProposerPreferences.Add(ProposerPreferencesKey{Slot: 100, DependentRoot: root}, &cltypes.SignedProposerPreferences{}) + p.HighestBids.Add(HighestBidKey{Slot: 100, ParentBlockRoot: root}, &cltypes.SignedExecutionPayloadBid{}) + } + + _, preferencesFound := p.ProposerPreferences.Get(ProposerPreferencesKey{Slot: 100}) + _, bidFound := p.HighestBids.Get(HighestBidKey{Slot: 100}) + require.True(t, preferencesFound) + require.True(t, bidFound) +} + +func TestEpbsPoolPrunesEntriesBeforeSlot(t *testing.T) { + p := NewEpbsPool() + p.ProposerPreferences.Add(ProposerPreferencesKey{Slot: 99}, &cltypes.SignedProposerPreferences{}) + p.ProposerPreferences.Add(ProposerPreferencesKey{Slot: 100}, &cltypes.SignedProposerPreferences{}) + p.HighestBids.Add(HighestBidKey{Slot: 99}, &cltypes.SignedExecutionPayloadBid{}) + p.HighestBids.Add(HighestBidKey{Slot: 100}, &cltypes.SignedExecutionPayloadBid{}) + + p.ProposerPreferences.PruneSlotsBefore(100) + p.HighestBids.PruneSlotsBefore(100) + + _, oldPreferencesFound := p.ProposerPreferences.Get(ProposerPreferencesKey{Slot: 99}) + _, livePreferencesFound := p.ProposerPreferences.Get(ProposerPreferencesKey{Slot: 100}) + _, oldBidFound := p.HighestBids.Get(HighestBidKey{Slot: 99}) + _, liveBidFound := p.HighestBids.Get(HighestBidKey{Slot: 100}) + require.False(t, oldPreferencesFound) + require.True(t, livePreferencesFound) + require.False(t, oldBidFound) + require.True(t, liveBidFound) +} diff --git a/cl/spectest/consensus_tests/appendix.go b/cl/spectest/consensus_tests/appendix.go index 50de64e97fe..3b77ddcfd67 100644 --- a/cl/spectest/consensus_tests/appendix.go +++ b/cl/spectest/consensus_tests/appendix.go @@ -242,9 +242,7 @@ func addSszTests() { cltypes.NewPartialDataColumnSidecar, runAfterVersion(clparams.FuluVersion), )). With("PartialDataColumnPartsMetadata", sszStaticTestNewObjectByFunc( - func(v clparams.StateVersion) *cltypes.PartialDataColumnPartsMetadata { - return cltypes.NewPartialDataColumnPartsMetadata() - }, runAfterVersion(clparams.FuluVersion), + cltypes.NewPartialDataColumnPartsMetadata, runAfterVersion(clparams.FuluVersion), )). // [New in Gloas:EIP7732] GLOAS SSZ types With("Builder", sszStaticTestByEmptyObject(&cltypes.Builder{}, runAfterVersion(clparams.GloasVersion))). diff --git a/cl/spectest/consensus_tests/operations.go b/cl/spectest/consensus_tests/operations.go index 05bed3b5bc9..10aac25d60c 100644 --- a/cl/spectest/consensus_tests/operations.go +++ b/cl/spectest/consensus_tests/operations.go @@ -62,7 +62,11 @@ func operationAttestationHandler(t *testing.T, root fs.FS, c spectest.TestCase) if err := spectest.ReadSszOld(root, att, c.Version(), attestationFileName); err != nil { return err } - if err := c.Machine.ProcessAttestations(preState, solid.NewDynamicListSSZFromList([]*solid.Attestation{att}, 128)); err != nil { + var parentSlot uint64 + if preState.Version() >= clparams.GloasVersion && preState.GetLatestExecutionPayloadBid() != nil { + parentSlot = preState.GetLatestExecutionPayloadBid().Slot + } + if err := c.Machine.ProcessAttestations(preState, solid.NewDynamicListSSZFromList([]*solid.Attestation{att}, 128), parentSlot); err != nil { if expectedError { return nil } @@ -636,7 +640,7 @@ func operationExecutionPayloadBidHandler(t *testing.T, root fs.FS, c spectest.Te block.Slot = signedBid.Message.Slot block.ParentRoot = signedBid.Message.ParentBlockRoot block.Body.SignedExecutionPayloadBid = signedBid - if err := c.Machine.ProcessExecutionPayloadBid(preState, block); err != nil { + if _, err := c.Machine.ProcessExecutionPayloadBid(preState, block); err != nil { if expectedError { return nil } diff --git a/cl/transition/impl/eth2/operations.go b/cl/transition/impl/eth2/operations.go index 13913d0181c..f0bc2373f31 100644 --- a/cl/transition/impl/eth2/operations.go +++ b/cl/transition/impl/eth2/operations.go @@ -513,10 +513,10 @@ func updateNextWithdrawalBuilderIndex(s abstract.BeaconState, processedBuildersS // ProcessExecutionPayloadBid processes the execution payload bid from the block. // [New in Gloas:EIP7732] -func (imp *impl) ProcessExecutionPayloadBid(s abstract.BeaconState, block cltypes.GenericBeaconBlock) error { +func (imp *impl) ProcessExecutionPayloadBid(s abstract.BeaconState, block cltypes.GenericBeaconBlock) (uint64, error) { signedBid := block.GetBody().GetSignedExecutionPayloadBid() if signedBid == nil || signedBid.Message == nil { - return errors.New("processExecutionPayloadBid: signed bid or bid message is nil") + return 0, errors.New("processExecutionPayloadBid: signed bid or bid message is nil") } bid := signedBid.Message builderIndex := bid.BuilderIndex @@ -525,41 +525,41 @@ func (imp *impl) ProcessExecutionPayloadBid(s abstract.BeaconState, block cltype // For self-builds, amount must be zero regardless of withdrawal credential prefix if builderIndex == clparams.BuilderIndexSelfBuild { if amount != 0 { - return errors.New("processExecutionPayloadBid: self-build bid must have zero value") + return 0, errors.New("processExecutionPayloadBid: self-build bid must have zero value") } if signedBid.Signature != common.Bytes96(bls.InfiniteSignature) { - return errors.New("processExecutionPayloadBid: self-build bid must have infinite signature") + return 0, errors.New("processExecutionPayloadBid: self-build bid must have infinite signature") } } else { // Verify that the builder is active if !state.IsActiveBuilder(s, builderIndex) { - return errors.New("processExecutionPayloadBid: builder is not active") + return 0, errors.New("processExecutionPayloadBid: builder is not active") } builders := s.GetBuilders() if builders == nil || int(builderIndex) >= builders.Len() || builders.Get(int(builderIndex)) == nil { - return errors.New("processExecutionPayloadBid: invalid builder index") + return 0, errors.New("processExecutionPayloadBid: invalid builder index") } if builders.Get(int(builderIndex)).Version != s.BeaconConfig().PayloadBuilderVersion { - return errors.New("processExecutionPayloadBid: builder is not a payload builder") + return 0, errors.New("processExecutionPayloadBid: builder is not a payload builder") } // Verify that the builder has funds to cover the bid if !state.CanBuilderCoverBid(s, builderIndex, amount) { - return errors.New("processExecutionPayloadBid: builder cannot cover bid") + return 0, errors.New("processExecutionPayloadBid: builder cannot cover bid") } // Verify that the bid signature is valid valid, err := verifyExecutionPayloadBidSignature(s, signedBid) if err != nil { - return fmt.Errorf("processExecutionPayloadBid: failed to verify bid signature: %w", err) + return 0, fmt.Errorf("processExecutionPayloadBid: failed to verify bid signature: %w", err) } if !valid { - return errors.New("processExecutionPayloadBid: invalid bid signature") + return 0, errors.New("processExecutionPayloadBid: invalid bid signature") } } // Verify commitments are under limit epoch := state.Epoch(s) if bid.BlobKzgCommitments.Len() > int(s.BeaconConfig().GetBlobParameters(epoch).MaxBlobsPerBlock) { - return fmt.Errorf( + return 0, fmt.Errorf( "processExecutionPayloadBid: too many blob kzg commitments: %d > %d", bid.BlobKzgCommitments.Len(), s.BeaconConfig().GetBlobParameters(epoch).MaxBlobsPerBlock, @@ -567,35 +567,35 @@ func (imp *impl) ProcessExecutionPayloadBid(s abstract.BeaconState, block cltype } if bid.Slot != s.Slot() { - return fmt.Errorf("processExecutionPayloadBid: bid slot %d does not match state slot %d", bid.Slot, s.Slot()) + return 0, fmt.Errorf("processExecutionPayloadBid: bid slot %d does not match state slot %d", bid.Slot, s.Slot()) } if s.Slot() <= s.BeaconConfig().GenesisSlot { - return errors.New("processExecutionPayloadBid: bid at genesis slot") + return 0, errors.New("processExecutionPayloadBid: bid at genesis slot") } parentBid := s.GetLatestExecutionPayloadBid() if parentBid == nil { - return errors.New("processExecutionPayloadBid: state has no latest execution payload bid") + return 0, errors.New("processExecutionPayloadBid: state has no latest execution payload bid") } // Verify that the bid is for the right parent block if bid.ParentBlockHash != s.GetLatestBlockHash() { - return errors.New("processExecutionPayloadBid: parent block hash mismatch") + return 0, errors.New("processExecutionPayloadBid: parent block hash mismatch") } parentBlockRoot, err := s.GetBlockRootAtSlot(s.Slot() - 1) if err != nil { - return fmt.Errorf("processExecutionPayloadBid: failed to get parent block root: %w", err) + return 0, fmt.Errorf("processExecutionPayloadBid: failed to get parent block root: %w", err) } if bid.ParentBlockRoot != parentBlockRoot { - return errors.New("processExecutionPayloadBid: parent block root mismatch") + return 0, errors.New("processExecutionPayloadBid: parent block root mismatch") } if bid.PrevRandao != s.GetRandaoMixes(state.Epoch(s)) { - return errors.New("processExecutionPayloadBid: prev randao mismatch") + return 0, errors.New("processExecutionPayloadBid: prev randao mismatch") } // Record the pending payment if there is some payment if amount > 0 { proposerIndex, err := s.GetBeaconProposerIndex() if err != nil { - return fmt.Errorf("processExecutionPayloadBid: failed to get beacon proposer index: %w", err) + return 0, fmt.Errorf("processExecutionPayloadBid: failed to get beacon proposer index: %w", err) } pendingPayment := &cltypes.BuilderPendingPayment{ Weight: 0, @@ -613,10 +613,10 @@ func (imp *impl) ProcessExecutionPayloadBid(s abstract.BeaconState, block cltype s.SetBuilderPendingPayments(payments) } - // Cache the execution payload bid + parentSlot := parentBid.Slot s.SetLatestExecutionPayloadBid(bid) - return nil + return parentSlot, nil } // ApplyParentExecutionPayload applies the effects of a full parent execution payload to state. @@ -1157,13 +1157,14 @@ func (imp *impl) ProcessBlsToExecutionChange( func (imp *impl) ProcessAttestations( s abstract.BeaconState, attestations *solid.ListSSZ[*solid.Attestation], + parentSlot uint64, ) error { attestingIndiciesSet := make([][]uint64, attestations.Len()) baseRewardPerIncrement := s.BaseRewardPerIncrement() var err error if err := solid.RangeErr[*solid.Attestation](attestations, func(i int, a *solid.Attestation, _ int) error { - if attestingIndiciesSet[i], err = imp.processAttestation(s, a, baseRewardPerIncrement); err != nil { + if attestingIndiciesSet[i], err = imp.processAttestation(s, a, baseRewardPerIncrement, parentSlot); err != nil { return err } return nil @@ -1193,6 +1194,7 @@ func (imp *impl) processAttestationPostAltair( s abstract.BeaconState, attestation *solid.Attestation, baseRewardPerIncrement uint64, + parentSlot uint64, ) ([]uint64, error) { data := attestation.Data currentEpoch := state.Epoch(s) @@ -1244,6 +1246,7 @@ func (imp *impl) processAttestationPostAltair( participationFlagsIndicies, err := s.GetAttestationParticipationFlagIndicies( data, stateSlot-data.Slot, + parentSlot, false, ) if err != nil { @@ -1286,6 +1289,7 @@ func (imp *impl) processAttestationPostAltair( } baseReward := (val / beaconConfig.EffectiveBalanceIncrement) * baseRewardPerIncrement + hadNoParticipation := s.EpochParticipationForValidatorIndex(isCurrentEpoch, int(attesterIndex)) == 0 willSetNewFlag := false // [New in Gloas:EIP7732] for flagIndex, weight := range beaconConfig.ParticipationWeights() { flagParticipation := s.EpochParticipationForValidatorIndex( @@ -1307,6 +1311,7 @@ func (imp *impl) processAttestationPostAltair( if s.Version() >= clparams.GloasVersion && willSetNewFlag && + hadNoParticipation && isSameSlot && payment != nil && payment.Withdrawal != nil && payment.Withdrawal.Amount > 0 { paymentWeightDelta += val @@ -1494,6 +1499,7 @@ func (imp *impl) processAttestation( s abstract.BeaconState, attestation *solid.Attestation, baseRewardPerIncrement uint64, + parentSlot uint64, ) ([]uint64, error) { // Prelimary checks. if err := IsAttestationApplicable(s, attestation); err != nil { @@ -1503,7 +1509,7 @@ func (imp *impl) processAttestation( if s.Version() == clparams.Phase0Version { return imp.processAttestationPhase0(s, attestation) } - return imp.processAttestationPostAltair(s, attestation, baseRewardPerIncrement) + return imp.processAttestationPostAltair(s, attestation, baseRewardPerIncrement, parentSlot) } func verifyAttestations( diff --git a/cl/transition/machine/block.go b/cl/transition/machine/block.go index 853819a2375..3fad38eabb1 100644 --- a/cl/transition/machine/block.go +++ b/cl/transition/machine/block.go @@ -47,6 +47,7 @@ func ProcessBlock(impl BlockProcessor, s abstract.BeaconState, block cltypes.Gen } // [New in Gloas:EIP7732] process_parent_execution_payload BEFORE process_block_header + var parentSlot uint64 if version >= clparams.GloasVersion { if err := impl.ProcessParentExecutionPayload(s, block); err != nil { return fmt.Errorf("processBlock: failed to process parent execution payload: %w", err) @@ -68,7 +69,8 @@ func ProcessBlock(impl BlockProcessor, s abstract.BeaconState, block cltypes.Gen return fmt.Errorf("processBlock: failed to process withdrawals: %w", err) } // 3. [New in Gloas:EIP7732] process_execution_payload_bid(state, block) - if err := impl.ProcessExecutionPayloadBid(s, block); err != nil { + parentSlot, err = impl.ProcessExecutionPayloadBid(s, block) + if err != nil { return fmt.Errorf("processBlock: failed to process execution payload bid: %w", err) } } else if version >= clparams.BellatrixVersion { @@ -111,7 +113,7 @@ func ProcessBlock(impl BlockProcessor, s abstract.BeaconState, block cltypes.Gen } // 6. process_operations - sigs, msgs, pubKeys, err = ProcessOperations(impl, s, body) + sigs, msgs, pubKeys, err = ProcessOperations(impl, s, body, parentSlot) if err != nil { return fmt.Errorf("processBlock: failed to process block body operations: %w", err) } @@ -139,7 +141,7 @@ func ProcessBlock(impl BlockProcessor, s abstract.BeaconState, block cltypes.Gen } // ProcessOperations is called by ProcessBlock and processes the block body operations -func ProcessOperations(impl BlockOperationProcessor, s abstract.BeaconState, blockBody cltypes.GenericBeaconBody) (signatures [][]byte, messages [][]byte, publicKeys [][]byte, err error) { +func ProcessOperations(impl BlockOperationProcessor, s abstract.BeaconState, blockBody cltypes.GenericBeaconBody, parentSlot uint64) (signatures [][]byte, messages [][]byte, publicKeys [][]byte, err error) { switch { case s.Version() <= clparams.DenebVersion: maxDepositsAllowed := int(min(s.BeaconConfig().MaxDeposits, s.Eth1Data().DepositCount-s.Eth1DepositIndex())) @@ -179,7 +181,7 @@ func ProcessOperations(impl BlockOperationProcessor, s abstract.BeaconState, blo } // Process each attestations - if err := impl.ProcessAttestations(s, blockBody.GetAttestations()); err != nil { + if err := impl.ProcessAttestations(s, blockBody.GetAttestations(), parentSlot); err != nil { return nil, nil, nil, fmt.Errorf("ProcessAttestation: %w", err) } diff --git a/cl/transition/machine/block_gloas_test.go b/cl/transition/machine/block_gloas_test.go index 1dd448337af..51d312582b2 100644 --- a/cl/transition/machine/block_gloas_test.go +++ b/cl/transition/machine/block_gloas_test.go @@ -20,7 +20,7 @@ func (noopBlockOperationProcessor) ProcessProposerSlashing(abstract.BeaconState, func (noopBlockOperationProcessor) ProcessAttesterSlashing(abstract.BeaconState, *cltypes.AttesterSlashing) error { return nil } -func (noopBlockOperationProcessor) ProcessAttestations(abstract.BeaconState, *solid.ListSSZ[*solid.Attestation]) error { +func (noopBlockOperationProcessor) ProcessAttestations(abstract.BeaconState, *solid.ListSSZ[*solid.Attestation], uint64) error { return nil } func (noopBlockOperationProcessor) ProcessDeposit(abstract.BeaconState, *cltypes.Deposit) error { @@ -90,7 +90,7 @@ func TestProcessOperationsRejectsOversizedGloasLists(t *testing.T) { require.NoError(t, validateGloasOperationCounts(body, &cfg)) test.append(body) - _, _, _, err := ProcessOperations(noopBlockOperationProcessor{}, s, body) + _, _, _, err := ProcessOperations(noopBlockOperationProcessor{}, s, body, 0) require.ErrorContains(t, err, "too many "+test.name) }) } diff --git a/cl/transition/machine/machine.go b/cl/transition/machine/machine.go index 99d57315f78..e1fef0792f3 100644 --- a/cl/transition/machine/machine.go +++ b/cl/transition/machine/machine.go @@ -48,7 +48,7 @@ type BlockHeaderProcessor interface { ProcessBlockHeader(s abstract.BeaconState, slot, proposerIndex uint64, parentRoot common.Hash, bodyRoot [32]byte) error ProcessWithdrawals(s abstract.BeaconState, withdrawals *solid.ListSSZ[*cltypes.Withdrawal]) error ProcessExecutionPayload(s abstract.BeaconState, body cltypes.GenericBeaconBody) error - ProcessExecutionPayloadBid(s abstract.BeaconState, block cltypes.GenericBeaconBlock) error + ProcessExecutionPayloadBid(s abstract.BeaconState, block cltypes.GenericBeaconBlock) (uint64, error) ProcessParentExecutionPayload(s abstract.BeaconState, block cltypes.GenericBeaconBlock) error ApplyParentExecutionPayload(s abstract.BeaconState, requests *cltypes.ExecutionRequests) error ProcessRandao(s abstract.BeaconState, randao [96]byte, proposerIndex uint64) error @@ -61,7 +61,7 @@ type BlockHeaderProcessor interface { type BlockOperationProcessor interface { ProcessProposerSlashing(s abstract.BeaconState, propSlashing *cltypes.ProposerSlashing) error ProcessAttesterSlashing(s abstract.BeaconState, attSlashing *cltypes.AttesterSlashing) error - ProcessAttestations(s abstract.BeaconState, attestations *solid.ListSSZ[*solid.Attestation]) error + ProcessAttestations(s abstract.BeaconState, attestations *solid.ListSSZ[*solid.Attestation], parentSlot uint64) error ProcessDeposit(s abstract.BeaconState, deposit *cltypes.Deposit) error ProcessVoluntaryExit(s abstract.BeaconState, signedVoluntaryExit *cltypes.SignedVoluntaryExit) error ProcessBlsToExecutionChange(state abstract.BeaconState, signedChange *cltypes.SignedBLSToExecutionChange) error diff --git a/test-fixtures.json b/test-fixtures.json index 5f2115fa1a5..2b2c301d3be 100644 --- a/test-fixtures.json +++ b/test-fixtures.json @@ -21,9 +21,9 @@ "size": 497764775 }, "cl_mainnet": { - "url": "https://github.com/ethereum/consensus-specs/releases/download/v1.7.0-alpha.12/mainnet.tar.gz", - "sha256": "f0057d2acdea2730ec68cac2c796aad89174e10e9b38ac68ad7d8aa1c0cbd85c", - "size": 858531022 + "url": "https://github.com/ethereum/consensus-specs/releases/download/v1.7.0-alpha.14/mainnet.tar.gz", + "sha256": "f98bd32f4d68867691b910ba1c62e3d55dd4e8b6a57a785b3dfd9297bf9800f7", + "size": 898796310 }, "legacy_cancun": { "url": "https://github.com/ethereum/legacytests/archive/1f581b8ccdc4c63acf5f2c5c1b155c690c32a8eb.tar.gz", From d42060d670c25d7ee5845d16c6a312a49af50397 Mon Sep 17 00:00:00 2001 From: kewei Date: Tue, 25 Aug 2026 17:31:07 +0800 Subject: [PATCH 02/43] .github: use devnet 8 images in Gloas Kurtosis --- .github/workflows/kurtosis/gloas-caplin-mixed.io | 6 +++--- .github/workflows/kurtosis/gloas-three-cl-mixed.io | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/kurtosis/gloas-caplin-mixed.io b/.github/workflows/kurtosis/gloas-caplin-mixed.io index e69692cd4bd..a79c0683955 100644 --- a/.github/workflows/kurtosis/gloas-caplin-mixed.io +++ b/.github/workflows/kurtosis/gloas-caplin-mixed.io @@ -1,6 +1,6 @@ participants: - cl_type: lighthouse - cl_image: ethpandaops/lighthouse:glamsterdam-devnet-7 + cl_image: ethpandaops/lighthouse:glamsterdam-devnet-8 el_type: erigon el_image: test/erigon:current el_log_level: "debug" @@ -17,7 +17,7 @@ participants: el_extra_params: ["--experimental.bal"] use_separate_vc: true vc_type: lighthouse - vc_image: ethpandaops/lighthouse:glamsterdam-devnet-7 + vc_image: ethpandaops/lighthouse:glamsterdam-devnet-8 count: 1 global_log_level: 'debug' network_params: @@ -27,7 +27,7 @@ network_params: fulu_fork_epoch: 0 gloas_fork_epoch: 1 ethereum_genesis_generator_params: - image: ethpandaops/ethereum-genesis-generator:6.1.4 + image: ethpandaops/ethereum-genesis-generator:6.2.0 additional_services: [assertoor] assertoor_params: run_stability_check: false diff --git a/.github/workflows/kurtosis/gloas-three-cl-mixed.io b/.github/workflows/kurtosis/gloas-three-cl-mixed.io index 5c60a0370e3..2ba647a0a7e 100644 --- a/.github/workflows/kurtosis/gloas-three-cl-mixed.io +++ b/.github/workflows/kurtosis/gloas-three-cl-mixed.io @@ -1,6 +1,6 @@ participants: - cl_type: lighthouse - cl_image: ethpandaops/lighthouse:glamsterdam-devnet-7 + cl_image: ethpandaops/lighthouse:glamsterdam-devnet-8 el_type: erigon el_image: test/erigon:current el_log_level: "debug" @@ -8,14 +8,14 @@ participants: supernode: true count: 1 - cl_type: prysm - cl_image: ethpandaops/prysm-beacon-chain:glamsterdam-devnet-7-minimal + cl_image: ethpandaops/prysm-beacon-chain:glamsterdam-devnet-8 el_type: erigon el_image: test/erigon:current el_log_level: "debug" el_extra_params: ["--experimental.bal"] use_separate_vc: true vc_type: prysm - vc_image: ethpandaops/prysm-validator:glamsterdam-devnet-7-minimal + vc_image: ethpandaops/prysm-validator:glamsterdam-devnet-8 count: 1 - cl_type: caplin cl_image: test/erigon:current @@ -27,7 +27,7 @@ participants: el_extra_params: ["--experimental.bal"] use_separate_vc: true vc_type: lighthouse - vc_image: ethpandaops/lighthouse:glamsterdam-devnet-7 + vc_image: ethpandaops/lighthouse:glamsterdam-devnet-8 count: 1 global_log_level: 'debug' network_params: @@ -37,7 +37,7 @@ network_params: fulu_fork_epoch: 0 gloas_fork_epoch: 1 ethereum_genesis_generator_params: - image: ethpandaops/ethereum-genesis-generator:6.1.4 + image: ethpandaops/ethereum-genesis-generator:6.2.0 additional_services: [assertoor] assertoor_params: run_stability_check: false From 5ad373a610aef03fa6da7a5ed3cf64ecd35edee4 Mon Sep 17 00:00:00 2001 From: kewei Date: Tue, 25 Aug 2026 19:09:26 +0800 Subject: [PATCH 03/43] cl: implement Gloas builder beacon APIs --- cl/beacon/beaconevents/gloas_events_test.go | 51 ++ cl/beacon/beaconevents/model.go | 54 ++ cl/beacon/beaconevents/operation_feed.go | 12 + cl/beacon/beaconevents/state_feed.go | 4 + cl/beacon/builder/client.go | 181 ++++ cl/beacon/builder/client_test.go | 147 ++++ cl/beacon/builder/interface.go | 4 + .../mock_services/builder_client_mock.go | 120 ++- cl/beacon/handler/block_production.go | 347 ++++++-- cl/beacon/handler/block_production_test.go | 227 ++++- cl/beacon/handler/block_production_v4.go | 180 ++++ cl/beacon/handler/builder.go | 221 +++++ cl/beacon/handler/builder_preferences.go | 90 ++ cl/beacon/handler/builder_preferences_test.go | 116 +++ cl/beacon/handler/epbs.go | 256 +++++- cl/beacon/handler/epbs_test.go | 125 +++ cl/beacon/handler/events.go | 5 + cl/beacon/handler/events_gloas_test.go | 40 + cl/beacon/handler/handler.go | 13 + cl/beacon/handler/state_builders_test.go | 152 ++++ cl/cltypes/epbs_builder_api.go | 804 ++++++++++++++++++ cl/cltypes/epbs_builder_api_test.go | 191 +++++ cl/cltypes/epbs_payload.go | 63 +- cl/cltypes/execution_requests.go | 20 +- cl/cltypes/gloas_api.go | 90 ++ cl/cltypes/gloas_api_test.go | 131 +++ .../services/execution_payload_bid_service.go | 51 ++ .../execution_payload_bid_service_test.go | 16 + .../services/execution_payload_service.go | 44 + .../execution_payload_service_test.go | 40 + .../services/proposer_preferences_service.go | 7 + .../proposer_preferences_service_test.go | 18 + cl/phase1/stages/forkchoice.go | 31 +- cmd/caplin/caplin1/run.go | 2 +- 34 files changed, 3752 insertions(+), 101 deletions(-) create mode 100644 cl/beacon/beaconevents/gloas_events_test.go create mode 100644 cl/beacon/handler/block_production_v4.go create mode 100644 cl/beacon/handler/builder_preferences.go create mode 100644 cl/beacon/handler/builder_preferences_test.go create mode 100644 cl/beacon/handler/events_gloas_test.go create mode 100644 cl/beacon/handler/state_builders_test.go create mode 100644 cl/cltypes/epbs_builder_api.go create mode 100644 cl/cltypes/epbs_builder_api_test.go create mode 100644 cl/cltypes/gloas_api.go create mode 100644 cl/cltypes/gloas_api_test.go diff --git a/cl/beacon/beaconevents/gloas_events_test.go b/cl/beacon/beaconevents/gloas_events_test.go new file mode 100644 index 00000000000..277150400ff --- /dev/null +++ b/cl/beacon/beaconevents/gloas_events_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package beaconevents + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cl/cltypes" + "github.com/erigontech/erigon/common" +) + +func TestGloasEventFeeds(t *testing.T) { + emitter := NewEventEmitter() + stateEvents := make(chan *EventStream, 1) + stateSubscription := emitter.State().Subscribe(stateEvents) + defer stateSubscription.Unsubscribe() + operationEvents := make(chan *EventStream, 3) + operationSubscription := emitter.Operation().Subscribe(operationEvents) + defer operationSubscription.Unsubscribe() + + head := &HeadV2Data{Version: "gloas", Data: HeadV2Content{Block: common.Hash{1}, PayloadStatus: "full"}} + emitter.State().SendHeadV2(head) + require.Equal(t, &EventStream{Event: StateHeadV2, Data: head}, <-stateEvents) + + payload := &ExecutionPayloadData{BlockRoot: common.Hash{2}} + emitter.Operation().SendExecutionPayload(payload) + require.Equal(t, &EventStream{Event: OpExecutionPayload, Data: payload}, <-operationEvents) + payloadGossip := &ExecutionPayloadGossipData{BlockRoot: common.Hash{2}} + emitter.Operation().SendExecutionPayloadGossip(payloadGossip) + require.Equal(t, &EventStream{Event: OpExecutionPayloadGossip, Data: payloadGossip}, <-operationEvents) + + preferences := &VersionedSignedProposerPreferences{Version: "gloas", Data: &cltypes.SignedProposerPreferences{}} + emitter.Operation().SendProposerPreferences(preferences) + require.Equal(t, &EventStream{Event: OpProposerPreferences, Data: preferences}, <-operationEvents) +} diff --git a/cl/beacon/beaconevents/model.go b/cl/beacon/beaconevents/model.go index a163c5ceb8b..f50b7697e7b 100644 --- a/cl/beacon/beaconevents/model.go +++ b/cl/beacon/beaconevents/model.go @@ -28,6 +28,9 @@ const ( OpPayloadAttestationMessage EventTopic = "payload_attestation_message" // [New in Gloas:EIP7732] OpExecutionPayloadBid EventTopic = "execution_payload_bid" // [New in Gloas:EIP7732] OpExecutionPayloadAvailable EventTopic = "execution_payload_available" // [New in Gloas:EIP7732] + OpExecutionPayload EventTopic = "execution_payload" + OpExecutionPayloadGossip EventTopic = "execution_payload_gossip" + OpProposerPreferences EventTopic = "proposer_preferences" ) type ( @@ -73,6 +76,7 @@ func NewDataColumnSidecarData(sidecar *cltypes.DataColumnSidecar) *DataColumnSid // State event topics const ( StateHead EventTopic = "head" + StateHeadV2 EventTopic = "head_v2" StateBlock EventTopic = "block" StateBlockGossip EventTopic = "block_gossip" StateFinalizedCheckpoint EventTopic = "finalized_checkpoint" @@ -80,6 +84,7 @@ const ( StateLightClientFinalityUpdate EventTopic = "light_client_finality_update" StateLightClientOptimisticUpdate EventTopic = "light_client_optimistic_update" StatePayloadAttributes EventTopic = "payload_attributes" + StateFastConfirmation EventTopic = "fast_confirmation" ) // State event data types @@ -93,6 +98,55 @@ type HeadData struct { ExecutionOptimistic bool `json:"execution_optimistic"` } +type HeadV2Data struct { + Version string `json:"version"` + Data HeadV2Content `json:"data"` +} + +type HeadV2Content struct { + Slot uint64 `json:"slot,string"` + Block common.Hash `json:"block"` + State common.Hash `json:"state"` + PayloadStatus string `json:"payload_status"` + EpochTransition bool `json:"epoch_transition"` + CurrentEpochDependentRoot common.Hash `json:"current_epoch_dependent_root"` + NextEpochDependentRoot common.Hash `json:"next_epoch_dependent_root"` + ExecutionOptimistic bool `json:"execution_optimistic"` +} + +type ExecutionPayloadData struct { + Slot uint64 `json:"slot,string"` + BuilderIndex uint64 `json:"builder_index,string"` + BlockHash common.Hash `json:"block_hash"` + BlockRoot common.Hash `json:"block_root"` + ExecutionOptimistic bool `json:"execution_optimistic"` +} + +type ExecutionPayloadGossipData struct { + Slot uint64 `json:"slot,string"` + BuilderIndex uint64 `json:"builder_index,string"` + BlockHash common.Hash `json:"block_hash"` + BlockRoot common.Hash `json:"block_root"` +} + +type VersionedSignedProposerPreferences struct { + Version string `json:"version"` + Data *cltypes.SignedProposerPreferences `json:"data"` +} + +func PayloadStatusName(status cltypes.PayloadStatus) string { + switch status { + case cltypes.PayloadStatusEmpty: + return "empty" + case cltypes.PayloadStatusFull: + return "full" + case cltypes.PayloadStatusPending: + return "pending" + default: + return "unknown" + } +} + type BlockData struct { Slot uint64 `json:"slot,string"` Block common.Hash `json:"block"` diff --git a/cl/beacon/beaconevents/operation_feed.go b/cl/beacon/beaconevents/operation_feed.go index 70918542b04..a0a7536279d 100644 --- a/cl/beacon/beaconevents/operation_feed.go +++ b/cl/beacon/beaconevents/operation_feed.go @@ -102,3 +102,15 @@ func (f *operationFeed) SendExecutionPayloadAvailable(value *ExecutionPayloadAva Data: value, }) } + +func (f *operationFeed) SendExecutionPayload(value *ExecutionPayloadData) int { + return f.feed.Send(&EventStream{Event: OpExecutionPayload, Data: value}) +} + +func (f *operationFeed) SendExecutionPayloadGossip(value *ExecutionPayloadGossipData) int { + return f.feed.Send(&EventStream{Event: OpExecutionPayloadGossip, Data: value}) +} + +func (f *operationFeed) SendProposerPreferences(value *VersionedSignedProposerPreferences) int { + return f.feed.Send(&EventStream{Event: OpProposerPreferences, Data: value}) +} diff --git a/cl/beacon/beaconevents/state_feed.go b/cl/beacon/beaconevents/state_feed.go index 3d940cf59e9..7366ade1151 100644 --- a/cl/beacon/beaconevents/state_feed.go +++ b/cl/beacon/beaconevents/state_feed.go @@ -25,6 +25,10 @@ func (f *stateFeed) SendHead(value *HeadData) int { }) } +func (f *stateFeed) SendHeadV2(value *HeadV2Data) int { + return f.feed.Send(&EventStream{Event: StateHeadV2, Data: value}) +} + // The node has received a block (from P2P or API) that is successfully imported on the fork-choice on_block handler func (f *stateFeed) SendBlock(value *BlockData) int { return f.feed.Send(&EventStream{ diff --git a/cl/beacon/builder/client.go b/cl/beacon/builder/client.go index b4f0113f0c4..4b607affe40 100644 --- a/cl/beacon/builder/client.go +++ b/cl/beacon/builder/client.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "strconv" @@ -40,11 +41,14 @@ var _ BuilderClient = &builderClient{} var ErrNoContent = errors.New("no http content") +const maxBuilderResponseBodySize = 1 << 20 + type builderClient struct { // ref: https://ethereum.github.io/builder-specs/#/ httpClient *http.Client url *url.URL beaconConfig *clparams.BeaconChainConfig + lookupIP func(context.Context, string) ([]net.IPAddr, error) } func NewBlockBuilderClient(baseUrl string, beaconConfig *clparams.BeaconChainConfig) *builderClient { @@ -202,6 +206,183 @@ func (b *builderClient) GetStatus(ctx context.Context) error { return err } +func (b *builderClient) SubmitBuilderPreferences(ctx context.Context, builderURL string, proposerPubkey common.Bytes48, request *cltypes.BuilderPreferencesRequest) error { + if request == nil { + return errors.New("nil builder preferences request") + } + payload, err := json.Marshal(request) + if err != nil { + return err + } + targetURL, err := b.builderEndpoint(ctx, builderURL, "eth", "v1", "builder", "builder_preferences", proposerPubkey.Hex()) + if err != nil { + return err + } + response, err := b.builderCall(ctx, http.MethodPost, targetURL, map[string]string{ + "Eth-Consensus-Version": clparams.GloasVersion.String(), + }, bytes.NewReader(payload)) + if err != nil { + return err + } + if response.status != http.StatusAccepted { + return fmt.Errorf("builder preferences: unexpected status code %d", response.status) + } + return nil +} + +func (b *builderClient) RequestExecutionPayloadBid(ctx context.Context, builderURL string, slot uint64, parentHash, parentRoot common.Hash, proposerPubkey common.Bytes48, auth *cltypes.SignedBuilderRequestAuth, timeout time.Duration) (*cltypes.SignedExecutionPayloadBid, error) { + if auth == nil { + return nil, errors.New("nil builder request auth") + } + timeoutMilliseconds := timeout.Milliseconds() + if timeoutMilliseconds <= 0 { + return nil, errors.New("builder request timeout must be at least one millisecond") + } + requestContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + payload, err := json.Marshal(auth) + if err != nil { + return nil, err + } + targetURL, err := b.builderEndpoint(requestContext, builderURL, "eth", "v1", "builder", "execution_payload_bid", strconv.FormatUint(slot, 10), parentHash.Hex(), parentRoot.Hex(), proposerPubkey.Hex()) + if err != nil { + return nil, err + } + response, err := b.builderCall(requestContext, http.MethodPost, targetURL, map[string]string{ + "Eth-Consensus-Version": clparams.GloasVersion.String(), + "Date-Milliseconds": strconv.FormatInt(time.Now().UnixMilli(), 10), + "X-Timeout-Ms": strconv.FormatInt(timeoutMilliseconds, 10), + }, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + if response.status == http.StatusNoContent { + return nil, nil + } + if response.status != http.StatusOK { + return nil, fmt.Errorf("execution payload bid: unexpected status code %d", response.status) + } + if response.header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() { + return nil, errors.New("execution payload bid response is missing the Gloas consensus version") + } + var responseBody struct { + Version string `json:"version"` + Data *cltypes.SignedExecutionPayloadBid `json:"data"` + } + if err := json.Unmarshal(response.body, &responseBody); err != nil { + return nil, fmt.Errorf("decode execution payload bid: %w", err) + } + if responseBody.Version != clparams.GloasVersion.String() || responseBody.Data == nil || responseBody.Data.Message == nil { + return nil, errors.New("execution payload bid response is missing Gloas data") + } + return responseBody.Data, nil +} + +func (b *builderClient) SubmitSignedBeaconBlock(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error { + if block == nil || block.Block == nil || block.Block.Body == nil { + return errors.New("nil signed beacon block") + } + payload, err := json.Marshal(block) + if err != nil { + return err + } + targetURL, err := b.builderEndpoint(ctx, builderURL, "eth", "v1", "builder", "beacon_blocks") + if err != nil { + return err + } + response, err := b.builderCall(ctx, http.MethodPost, targetURL, map[string]string{ + "Eth-Consensus-Version": block.Version().String(), + }, bytes.NewReader(payload)) + if err != nil { + return err + } + if response.status != http.StatusAccepted { + return fmt.Errorf("signed beacon block: unexpected status code %d", response.status) + } + return nil +} + +func (b *builderClient) builderEndpoint(ctx context.Context, rawURL string, path ...string) (string, error) { + target, err := url.Parse(rawURL) + if err != nil { + return "", err + } + if (target.Scheme != "http" && target.Scheme != "https") || target.Host == "" || target.User != nil { + return "", errors.New("builder URL must be an HTTP(S) URL without user information") + } + hostname := target.Hostname() + if hostname == "localhost" { + return "", errors.New("builder URL resolves to a non-public address") + } + addresses := []net.IPAddr(nil) + if ip := net.ParseIP(hostname); ip != nil { + addresses = []net.IPAddr{{IP: ip}} + } else { + lookup := b.lookupIP + if lookup == nil { + lookup = net.DefaultResolver.LookupIPAddr + } + addresses, err = lookup(ctx, hostname) + if err != nil { + return "", fmt.Errorf("resolve builder URL: %w", err) + } + } + if len(addresses) == 0 { + return "", errors.New("builder URL has no resolved addresses") + } + for _, address := range addresses { + if !isPublicBuilderIP(address.IP) { + return "", fmt.Errorf("builder URL resolves to non-public address %s", address.IP) + } + } + return target.JoinPath(path...).String(), nil +} + +func isPublicBuilderIP(ip net.IP) bool { + return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsUnspecified() +} + +type builderHTTPResponse struct { + body []byte + header http.Header + status int +} + +func (b *builderClient) builderCall(ctx context.Context, method, targetURL string, headers map[string]string, body io.Reader) (*builderHTTPResponse, error) { + request, err := http.NewRequestWithContext(ctx, method, targetURL, body) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + for name, value := range headers { + request.Header.Set(name, value) + } + if b.httpClient == nil { + return nil, errors.New("nil builder HTTP client") + } + client := *b.httpClient + client.CheckRedirect = func(*http.Request, []*http.Request) error { + return errors.New("builder redirects are not allowed") + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + bodyBytes, err := io.ReadAll(io.LimitReader(response.Body, maxBuilderResponseBodySize+1)) + if err != nil { + return nil, err + } + if len(bodyBytes) > maxBuilderResponseBodySize { + return nil, fmt.Errorf("builder response exceeds %d bytes", maxBuilderResponseBodySize) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("builder returned status code %d: %s", response.StatusCode, string(bodyBytes)) + } + return &builderHTTPResponse{body: bodyBytes, header: response.Header.Clone(), status: response.StatusCode}, nil +} + func httpCall[T any](ctx context.Context, client *http.Client, method, rawURL string, headers map[string]string, payloadReader io.Reader, body T) (*T, error) { request, err := http.NewRequestWithContext(ctx, method, rawURL, payloadReader) if err != nil { diff --git a/cl/beacon/builder/client_test.go b/cl/beacon/builder/client_test.go index d6dd64ba408..4d2a79bd3c6 100644 --- a/cl/beacon/builder/client_test.go +++ b/cl/beacon/builder/client_test.go @@ -24,9 +24,14 @@ import ( "fmt" "io" "math" + "net" "net/http" "net/url" + "strconv" + "strings" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/require" @@ -334,6 +339,148 @@ func TestSubmitBlindedBlocks(t *testing.T) { }) } +func TestRequestExecutionPayloadBid(t *testing.T) { + auth := validBuilderRequestAuth() + proposer := common.Bytes48{1} + parentHash := common.Hash{2} + parentRoot := common.Hash{3} + client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "/eth/v1/builder/execution_payload_bid/12/"+parentHash.Hex()+"/"+parentRoot.Hex()+"/"+proposer.Hex(), r.URL.Path) + require.Equal(t, "gloas", r.Header.Get("Eth-Consensus-Version")) + require.Equal(t, "750", r.Header.Get("X-Timeout-Ms")) + date, err := strconv.ParseInt(r.Header.Get("Date-Milliseconds"), 10, 64) + require.NoError(t, err) + require.NotZero(t, date) + var got cltypes.SignedBuilderRequestAuth + require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) + require.Equal(t, auth, &got) + return builderTestResponse(r, http.StatusOK, `{"version":"gloas","data":{"message":{"blob_kzg_commitments":[]},"signature":"0x`+strings.Repeat("00", 96)+`"}}`, http.Header{"Eth-Consensus-Version": {"gloas"}}), nil + })) + bid, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 12, parentHash, parentRoot, proposer, auth, 750*time.Millisecond) + require.NoError(t, err) + require.NotNil(t, bid) +} + +func TestRequestExecutionPayloadBidNoContent(t *testing.T) { + client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { + return builderTestResponse(r, http.StatusNoContent, "", nil), nil + })) + + bid, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second) + require.NoError(t, err) + require.Nil(t, bid) +} + +func TestRequestExecutionPayloadBidEnforcesTimeout(t *testing.T) { + client := publicBuilderTestClient(mockRoundTripper(func(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() + })) + started := time.Now() + bid, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), 10*time.Millisecond) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, bid) + require.Less(t, time.Since(started), time.Second) +} + +func TestBuilderTransportRejectsUnsafeTargets(t *testing.T) { + for _, rawURL := range []string{"http://127.0.0.1:18550", "http://[::1]:18550", "http://localhost:18550"} { + client := &builderClient{httpClient: &http.Client{Transport: mockRoundTripper(func(*http.Request) (*http.Response, error) { + t.Fatal("unsafe target reached transport") + return nil, nil + })}} + _, err := client.RequestExecutionPayloadBid(context.Background(), rawURL, 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second) + require.Error(t, err, rawURL) + } + + client := &builderClient{ + httpClient: &http.Client{Transport: mockRoundTripper(func(*http.Request) (*http.Response, error) { + t.Fatal("private resolved address reached transport") + return nil, nil + })}, + lookupIP: func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("10.0.0.1")}}, nil + }, + } + _, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second) + require.Error(t, err) +} + +func TestRequestExecutionPayloadBidBoundsResponseAndRefusesRedirect(t *testing.T) { + t.Run("bounded response", func(t *testing.T) { + client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { + return builderTestResponse(r, http.StatusOK, string(make([]byte, maxBuilderResponseBodySize+1)), nil), nil + })) + bid, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second) + require.Error(t, err) + require.Nil(t, bid) + }) + + t.Run("redirect", func(t *testing.T) { + var redirected atomic.Bool + client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "redirected.example" { + redirected.Store(true) + } + return builderTestResponse(r, http.StatusTemporaryRedirect, "", http.Header{"Location": {"https://redirected.example"}}), nil + })) + bid, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second) + require.Error(t, err) + require.Nil(t, bid) + require.False(t, redirected.Load()) + }) +} + +func TestSubmitBuilderPreferences(t *testing.T) { + auth := validBuilderRequestAuth() + proposer := common.Bytes48{4} + request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{MaxExecutionPayment: 9}, Auth: auth} + client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "/eth/v1/builder/builder_preferences/"+proposer.Hex(), r.URL.Path) + require.Equal(t, "gloas", r.Header.Get("Eth-Consensus-Version")) + var got cltypes.BuilderPreferencesRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) + require.Equal(t, request, &got) + return builderTestResponse(r, http.StatusAccepted, "", nil), nil + })) + require.NoError(t, client.SubmitBuilderPreferences(context.Background(), "https://builder.example", proposer, request)) +} + +func TestSubmitSignedBeaconBlock(t *testing.T) { + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "/eth/v1/builder/beacon_blocks", r.URL.Path) + require.Equal(t, "gloas", r.Header.Get("Eth-Consensus-Version")) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + expected, err := json.Marshal(block) + require.NoError(t, err) + require.JSONEq(t, string(expected), string(body)) + return builderTestResponse(r, http.StatusAccepted, "", nil), nil + })) + require.NoError(t, client.SubmitSignedBeaconBlock(context.Background(), "https://builder.example", block)) +} + +func validBuilderRequestAuth() *cltypes.SignedBuilderRequestAuth { + return &cltypes.SignedBuilderRequestAuth{ + Message: &cltypes.BuilderRequestAuth{Data: []byte("builder-auth"), Slot: 12}, + } +} + +func publicBuilderTestClient(transport http.RoundTripper) *builderClient { + return &builderClient{ + httpClient: &http.Client{Transport: transport}, + lookupIP: func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil + }, + beaconConfig: mockBeaconConfig, + } +} + +func builderTestResponse(request *http.Request, status int, body string, header http.Header) *http.Response { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: header, Request: request} +} + func TestSubmitBlindedBlocksFulu(t *testing.T) { ctx := context.Background() expectPath := mockUrl.JoinPath("/eth/v2/builder/blinded_blocks").String() diff --git a/cl/beacon/builder/interface.go b/cl/beacon/builder/interface.go index 124a4dd138e..f751905c3f0 100644 --- a/cl/beacon/builder/interface.go +++ b/cl/beacon/builder/interface.go @@ -18,6 +18,7 @@ package builder import ( "context" + "time" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/common" @@ -29,5 +30,8 @@ type BuilderClient interface { RegisterValidator(ctx context.Context, registers []*cltypes.ValidatorRegistration) error GetHeader(ctx context.Context, slot int64, parentHash common.Hash, pubKey common.Bytes48) (*ExecutionHeader, error) SubmitBlindedBlocks(ctx context.Context, block *cltypes.SignedBlindedBeaconBlock) (*cltypes.Eth1Block, *engine_types.BlobsBundle, *cltypes.ExecutionRequests, error) + SubmitBuilderPreferences(ctx context.Context, builderURL string, proposerPubkey common.Bytes48, request *cltypes.BuilderPreferencesRequest) error + RequestExecutionPayloadBid(ctx context.Context, builderURL string, slot uint64, parentHash, parentRoot common.Hash, proposerPubkey common.Bytes48, auth *cltypes.SignedBuilderRequestAuth, timeout time.Duration) (*cltypes.SignedExecutionPayloadBid, error) + SubmitSignedBeaconBlock(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error GetStatus(ctx context.Context) error } diff --git a/cl/beacon/builder/mock_services/builder_client_mock.go b/cl/beacon/builder/mock_services/builder_client_mock.go index 81654390dd0..7e51d900a15 100644 --- a/cl/beacon/builder/mock_services/builder_client_mock.go +++ b/cl/beacon/builder/mock_services/builder_client_mock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/erigontech/erigon/cl/beacon/builder (interfaces: BuilderClient) +// Source: ./cl/beacon/builder/interface.go // // Generated by this command: // -// mockgen -typed=true -destination=./mock_services/builder_client_mock.go -package=mock_services . BuilderClient +// mockgen -source=./cl/beacon/builder/interface.go -typed=true -destination=./cl/beacon/builder/mock_services/builder_client_mock.go -package=mock_services // // Package mock_services is a generated GoMock package. @@ -12,6 +12,7 @@ package mock_services import ( context "context" reflect "reflect" + time "time" builder "github.com/erigontech/erigon/cl/beacon/builder" cltypes "github.com/erigontech/erigon/cl/cltypes" @@ -159,6 +160,45 @@ func (c *MockBuilderClientRegisterValidatorCall) DoAndReturn(f func(context.Cont return c } +// RequestExecutionPayloadBid mocks base method. +func (m *MockBuilderClient) RequestExecutionPayloadBid(ctx context.Context, builderURL string, slot uint64, parentHash, parentRoot common.Hash, proposerPubkey common.Bytes48, auth *cltypes.SignedBuilderRequestAuth, timeout time.Duration) (*cltypes.SignedExecutionPayloadBid, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestExecutionPayloadBid", ctx, builderURL, slot, parentHash, parentRoot, proposerPubkey, auth, timeout) + ret0, _ := ret[0].(*cltypes.SignedExecutionPayloadBid) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestExecutionPayloadBid indicates an expected call of RequestExecutionPayloadBid. +func (mr *MockBuilderClientMockRecorder) RequestExecutionPayloadBid(ctx, builderURL, slot, parentHash, parentRoot, proposerPubkey, auth, timeout any) *MockBuilderClientRequestExecutionPayloadBidCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestExecutionPayloadBid", reflect.TypeOf((*MockBuilderClient)(nil).RequestExecutionPayloadBid), ctx, builderURL, slot, parentHash, parentRoot, proposerPubkey, auth, timeout) + return &MockBuilderClientRequestExecutionPayloadBidCall{Call: call} +} + +// MockBuilderClientRequestExecutionPayloadBidCall wrap *gomock.Call +type MockBuilderClientRequestExecutionPayloadBidCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockBuilderClientRequestExecutionPayloadBidCall) Return(arg0 *cltypes.SignedExecutionPayloadBid, arg1 error) *MockBuilderClientRequestExecutionPayloadBidCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockBuilderClientRequestExecutionPayloadBidCall) Do(f func(context.Context, string, uint64, common.Hash, common.Hash, common.Bytes48, *cltypes.SignedBuilderRequestAuth, time.Duration) (*cltypes.SignedExecutionPayloadBid, error)) *MockBuilderClientRequestExecutionPayloadBidCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockBuilderClientRequestExecutionPayloadBidCall) DoAndReturn(f func(context.Context, string, uint64, common.Hash, common.Hash, common.Bytes48, *cltypes.SignedBuilderRequestAuth, time.Duration) (*cltypes.SignedExecutionPayloadBid, error)) *MockBuilderClientRequestExecutionPayloadBidCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // SubmitBlindedBlocks mocks base method. func (m *MockBuilderClient) SubmitBlindedBlocks(ctx context.Context, block *cltypes.SignedBlindedBeaconBlock) (*cltypes.Eth1Block, *engine_types.BlobsBundle, *cltypes.ExecutionRequests, error) { m.ctrl.T.Helper() @@ -199,3 +239,79 @@ func (c *MockBuilderClientSubmitBlindedBlocksCall) DoAndReturn(f func(context.Co c.Call = c.Call.DoAndReturn(f) return c } + +// SubmitBuilderPreferences mocks base method. +func (m *MockBuilderClient) SubmitBuilderPreferences(ctx context.Context, builderURL string, proposerPubkey common.Bytes48, request *cltypes.BuilderPreferencesRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitBuilderPreferences", ctx, builderURL, proposerPubkey, request) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitBuilderPreferences indicates an expected call of SubmitBuilderPreferences. +func (mr *MockBuilderClientMockRecorder) SubmitBuilderPreferences(ctx, builderURL, proposerPubkey, request any) *MockBuilderClientSubmitBuilderPreferencesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitBuilderPreferences", reflect.TypeOf((*MockBuilderClient)(nil).SubmitBuilderPreferences), ctx, builderURL, proposerPubkey, request) + return &MockBuilderClientSubmitBuilderPreferencesCall{Call: call} +} + +// MockBuilderClientSubmitBuilderPreferencesCall wrap *gomock.Call +type MockBuilderClientSubmitBuilderPreferencesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockBuilderClientSubmitBuilderPreferencesCall) Return(arg0 error) *MockBuilderClientSubmitBuilderPreferencesCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockBuilderClientSubmitBuilderPreferencesCall) Do(f func(context.Context, string, common.Bytes48, *cltypes.BuilderPreferencesRequest) error) *MockBuilderClientSubmitBuilderPreferencesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockBuilderClientSubmitBuilderPreferencesCall) DoAndReturn(f func(context.Context, string, common.Bytes48, *cltypes.BuilderPreferencesRequest) error) *MockBuilderClientSubmitBuilderPreferencesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SubmitSignedBeaconBlock mocks base method. +func (m *MockBuilderClient) SubmitSignedBeaconBlock(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitSignedBeaconBlock", ctx, builderURL, block) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitSignedBeaconBlock indicates an expected call of SubmitSignedBeaconBlock. +func (mr *MockBuilderClientMockRecorder) SubmitSignedBeaconBlock(ctx, builderURL, block any) *MockBuilderClientSubmitSignedBeaconBlockCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitSignedBeaconBlock", reflect.TypeOf((*MockBuilderClient)(nil).SubmitSignedBeaconBlock), ctx, builderURL, block) + return &MockBuilderClientSubmitSignedBeaconBlockCall{Call: call} +} + +// MockBuilderClientSubmitSignedBeaconBlockCall wrap *gomock.Call +type MockBuilderClientSubmitSignedBeaconBlockCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockBuilderClientSubmitSignedBeaconBlockCall) Return(arg0 error) *MockBuilderClientSubmitSignedBeaconBlockCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockBuilderClientSubmitSignedBeaconBlockCall) Do(f func(context.Context, string, *cltypes.SignedBeaconBlock) error) *MockBuilderClientSubmitSignedBeaconBlockCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockBuilderClientSubmitSignedBeaconBlockCall) DoAndReturn(f func(context.Context, string, *cltypes.SignedBeaconBlock) error) *MockBuilderClientSubmitSignedBeaconBlockCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 05d40a6834f..7401aca8cbf 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -24,7 +24,9 @@ import ( "errors" "fmt" "io" + "math" "math/big" + "math/bits" "net/http" "slices" "strconv" @@ -618,8 +620,11 @@ func (a *ApiHandler) GetEthV3ValidatorBlock( log.Debug("[Beacon API] Producing block", "slot", targetSlot) // builder boost factor controls block choice between local execution node or builder builderBoostFactor := uint64(100) + if options := gloasBlockOptionsFromContext(ctx); options != nil { + builderBoostFactor = options.builderConfig.BuilderBoostFactor + } builderBoostFactorStr := r.URL.Query().Get("builder_boost_factor") - if builderBoostFactorStr != "" { + if builderBoostFactorStr != "" && gloasBlockOptionsFromContext(ctx) == nil { builderBoostFactor, err = strconv.ParseUint(builderBoostFactorStr, 10, 64) if err != nil { return nil, beaconhttp.NewEndpointError( @@ -718,7 +723,7 @@ func (a *ApiHandler) GetEthV3ValidatorBlock( resp = newBeaconResponse(block.ToExecution()) } resp = resp.WithVersion(block.Version()).With("execution_payload_blinded", block.IsBlinded()). - With("execution_payload_value", strconv.FormatUint(block.GetExecutionValue().Uint64(), 10)). + With("execution_payload_value", block.GetExecutionValue().String()). With("consensus_block_value", strconv.FormatUint(consensusValue, 10)) executionPayloadIncluded := false @@ -753,7 +758,21 @@ func (a *ApiHandler) GetEthV3ValidatorBlock( a.selfBuildEnvelopes.Add(targetSlot, envelope) // SSZ encoding only serializes Data, not Extra — only include // the envelope in JSON responses to keep the header truthful. - if !beaconhttp.WillEncodeSSZ(r.Header.Get("Accept")) { + options := gloasBlockOptionsFromContext(ctx) + switch { + case options != nil && options.includePayload: + contents := cltypes.NewGloasBlockContents(a.beaconChainCfg, targetSlot) + contents.Block = denebBlock.Block + contents.ExecutionPayloadEnvelope = envelope + contents.KZGProofs = denebBlock.KZGProofs + contents.Blobs = denebBlock.Blobs + resp = newBeaconResponse(contents). + WithVersion(block.Version()). + With("execution_payload_blinded", block.IsBlinded()). + With("execution_payload_value", block.GetExecutionValue().String()). + With("consensus_block_value", strconv.FormatUint(consensusValue, 10)) + executionPayloadIncluded = true + case options == nil && !beaconhttp.WillEncodeSSZ(r.Header.Get("Accept")): resp = resp.With("execution_payload_envelope", envelope) executionPayloadIncluded = true } @@ -771,9 +790,15 @@ func (a *ApiHandler) GetEthV3ValidatorBlock( block.Version(), block.IsBlinded(), executionPayloadIncluded, - block.GetExecutionValue().Uint64(), + block.GetExecutionValue(), consensusValue, ) + if options := gloasBlockOptionsFromContext(ctx); options != nil && options.selectedBuilderURL != "" { + w.Header().Set("Eth-Builder-Url", options.selectedBuilderURL) + if root, err := block.ToExecution().Block.HashSSZ(); err == nil { + a.builderRoutes.Add(root, options.selectedBuilderURL) + } + } return resp, nil } @@ -790,11 +815,12 @@ func (a *ApiHandler) produceBlock( ) (block *cltypes.BlindOrExecutionBeaconBlock, err error) { defer func() { reportProductionFailure(err, targetSlot) }() + stateVersion := a.beaconChainCfg.GetCurrentStateVersion(targetSlot / a.beaconChainCfg.SlotsPerEpoch) var wg sync.WaitGroup // produce beacon body var ( beaconBody *cltypes.BeaconBody - localExecValue uint64 + localExecValue *big.Int localErr error blobs []*cltypes.Blob kzgProofs []common.Bytes48 @@ -843,7 +869,7 @@ func (a *ApiHandler) produceBlock( defer func() { a.logger.Debug("MevBoost", "slot", targetSlot, "duration", time.Since(start)) }() - if a.routerCfg.Builder && a.builderClient != nil { + if stateVersion.Before(clparams.GloasVersion) && a.routerCfg.Builder && a.builderClient != nil { builderHeader, builderErr = a.getBuilderPayload(ctx, baseState, targetSlot) if builderErr != nil && !errors.Is(builderErr, errBuilderNotEnabled) { log.Warn("Failed to get builder payload", "err", builderErr) @@ -872,45 +898,82 @@ func (a *ApiHandler) produceBlock( ParentRoot: baseBlockRoot, Cfg: a.beaconChainCfg, } - stateVersion := a.beaconChainCfg.GetCurrentStateVersion(targetSlot / a.beaconChainCfg.SlotsPerEpoch) if !a.routerCfg.Builder || builderErr != nil || stateVersion.AfterOrEqual(clparams.GloasVersion) { // directly return the block if: // 1. builder is not enabled // 2. failed to get builder payload // 3. GLOAS: MEV-Boost blinded blocks not supported; builders use ePBS gossip bids - // GLOAS: check epbsPool for an external builder bid that beats the local value. + // GLOAS: check p2p and configured Builder API bids against the local value. if stateVersion.AfterOrEqual(clparams.GloasVersion) && a.epbsPool != nil { selfBid := beaconBody.SignedExecutionPayloadBid.Message + options := gloasBlockOptionsFromContext(ctx) + p2pMinBid := uint64(0) + if options != nil { + p2pMinBid = options.builderConfig.MinBid + } + candidates := make([]gloasBidCandidate, 0, 1) bidKey := pool.HighestBidKey{ Slot: targetSlot, ParentBlockHash: selfBid.ParentBlockHash, ParentBlockRoot: selfBid.ParentBlockRoot, } - if externalBid, found := a.epbsPool.HighestBids.Get(bidKey); found && - externalBid != nil && externalBid.Message != nil && - externalBid.Message.Value > localExecValue { + if externalBid, found := a.epbsPool.HighestBids.Get(bidKey); found { + candidates = append(candidates, gloasBidCandidate{ + bid: externalBid, + boostFactor: boostFactor, + maxExecutionPayment: math.MaxUint64, + minBid: p2pMinBid, + }) + } + if options != nil && a.builderClient != nil { + proposerPubkey, pubkeyErr := baseState.ValidatorPublicKey(int(proposerIndex)) + if pubkeyErr == nil { + candidates = append(candidates, a.requestConfiguredBuilderBids(ctx, baseState, targetSlot, proposerPubkey, selfBid, options.builderConfig.Builders)...) + } + } + if options != nil && options.suppliedBid != nil && options.suppliedBid.Message != nil && + options.suppliedBid.Message.ParentBlockHash == selfBid.ParentBlockHash && + options.suppliedBid.Message.ParentBlockRoot == selfBid.ParentBlockRoot { + validator, ok := a.executionPayloadBidService.(executionPayloadBidValidator) + if ok && validator.ValidateBid(ctx, options.suppliedBid) == nil { + candidates = append(candidates, gloasBidCandidate{ + bid: options.suppliedBid, + boostFactor: options.builderConfig.BuilderBoostFactor, + maxExecutionPayment: math.MaxUint64, + }) + } + } + selected := selectGloasBid(localExecValue, candidates) + if selected != nil { log.Info("GLOAS: selected external builder bid over self-build", "slot", targetSlot, - "builderIndex", externalBid.Message.BuilderIndex, - "bidValue", externalBid.Message.Value, + "builderIndex", selected.bid.Message.BuilderIndex, + "bidValue", selected.bid.Message.Value, "localValue", localExecValue) - beaconBody.SignedExecutionPayloadBid = externalBid - localExecValue = externalBid.Message.Value + beaconBody.SignedExecutionPayloadBid = selected.bid + block.BeaconBody = beaconBody + block.Blobs = blobs + block.KzgProofs = kzgProofs + block.ExecutionValue = selected.executionValueWei + if options != nil { + options.selectedBuilderURL = selected.builderURL + } + return block, nil } } block.BeaconBody = beaconBody block.Blobs = blobs block.KzgProofs = kzgProofs - block.ExecutionValue = new(big.Int).SetUint64(localExecValue) + block.ExecutionValue = new(big.Int).Set(localExecValue) return block, nil } // determine whether to use local execution node or builder // if exec_node_payload_value >= builder_boost_factor * (builder_payload_value // 100), then return a full (unblinded) block containing the execution node payload. // otherwise, return a blinded block containing the builder payload header. - execValue := new(big.Int).SetUint64(localExecValue) + execValue := new(big.Int).Set(localExecValue) builderValue := builderHeader.BlockValue() boostFactorBig := new(big.Int).SetUint64(boostFactor) useLocalExec := new(big.Int).Mul(execValue, big.NewInt(100)).Cmp(new(big.Int).Mul(builderValue, boostFactorBig)) >= 0 @@ -945,6 +1008,133 @@ func (a *ApiHandler) produceBlock( return block, nil } +type gloasBidCandidate struct { + bid *cltypes.SignedExecutionPayloadBid + boostFactor uint64 + maxExecutionPayment uint64 + minBid uint64 + builderURL string + executionValueWei *big.Int +} + +type executionPayloadBidValidator interface { + ValidateBid(context.Context, *cltypes.SignedExecutionPayloadBid) error +} + +func selectGloasBid(localExecutionValueWei *big.Int, candidates []gloasBidCandidate) *gloasBidCandidate { + if localExecutionValueWei == nil { + localExecutionValueWei = new(big.Int) + } + localWeighted := new(big.Int).Mul(new(big.Int).Set(localExecutionValueWei), big.NewInt(100)) + var best *gloasBidCandidate + bestWeighted := new(big.Int) + for i := range candidates { + candidate := &candidates[i] + if candidate.bid == nil || candidate.bid.Message == nil { + continue + } + countedExecutionPayment := min(candidate.bid.Message.ExecutionPayment, candidate.maxExecutionPayment) + selectionValue, carry := bits.Add64(candidate.bid.Message.Value, countedExecutionPayment, 0) + if carry != 0 { + selectionValue = math.MaxUint64 + } + if selectionValue < candidate.minBid { + continue + } + selectionValueWei := new(big.Int).Mul(new(big.Int).SetUint64(selectionValue), big.NewInt(1_000_000_000)) + weighted := new(big.Int).Mul(selectionValueWei, new(big.Int).SetUint64(candidate.boostFactor)) + if best == nil || weighted.Cmp(bestWeighted) > 0 { + actualValue, actualCarry := bits.Add64(candidate.bid.Message.Value, candidate.bid.Message.ExecutionPayment, 0) + if actualCarry != 0 { + actualValue = math.MaxUint64 + } + candidate.executionValueWei = new(big.Int).Mul(new(big.Int).SetUint64(actualValue), big.NewInt(1_000_000_000)) + best = candidate + bestWeighted.Set(weighted) + } + } + if best == nil || localWeighted.Cmp(bestWeighted) >= 0 { + return nil + } + return best +} + +func (a *ApiHandler) requestConfiguredBuilderBids( + ctx context.Context, + baseState *state.CachingBeaconState, + targetSlot uint64, + proposerPubkey common.Bytes48, + parentBid *cltypes.ExecutionPayloadBid, + entries []*cltypes.BuilderEntry, +) []gloasBidCandidate { + if parentBid == nil || len(entries) == 0 { + return nil + } + timeout := time.Second + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + if remaining <= 0 { + return nil + } + timeout = min(timeout, remaining) + } + type result struct { + entry *cltypes.BuilderEntry + bid *cltypes.SignedExecutionPayloadBid + } + results := make(chan result, len(entries)) + var wg sync.WaitGroup + for _, entry := range entries { + if entry == nil { + continue + } + wg.Go(func() { + bid, err := a.builderClient.RequestExecutionPayloadBid(ctx, entry.URL, targetSlot, + parentBid.ParentBlockHash, parentBid.ParentBlockRoot, proposerPubkey, entry.Auth, timeout) + if err != nil { + a.logger.Debug("Builder API bid request failed", "url", entry.URL, "err", err) + return + } + results <- result{entry: entry, bid: bid} + }) + } + wg.Wait() + close(results) + + builders := baseState.GetBuilders() + candidates := make([]gloasBidCandidate, 0, len(results)) + for result := range results { + bid := result.bid + if bid == nil || bid.Message == nil || bid.Message.Slot != targetSlot || + bid.Message.ParentBlockHash != parentBid.ParentBlockHash || bid.Message.ParentBlockRoot != parentBid.ParentBlockRoot { + continue + } + if builders == nil || bid.Message.BuilderIndex >= uint64(builders.Len()) { + continue + } + builderRecord := builders.Get(int(bid.Message.BuilderIndex)) + if builderRecord == nil || len(result.entry.BuilderPubkeys) != 0 && !slices.Contains(result.entry.BuilderPubkeys, builderRecord.Pubkey) { + continue + } + validator, ok := a.executionPayloadBidService.(executionPayloadBidValidator) + if !ok { + continue + } + if err := validator.ValidateBid(ctx, bid); err != nil { + a.logger.Debug("Builder API returned invalid bid", "url", result.entry.URL, "err", err) + continue + } + candidates = append(candidates, gloasBidCandidate{ + bid: bid, + boostFactor: result.entry.BuilderBoostFactor, + maxExecutionPayment: result.entry.MaxExecutionPayment, + minBid: result.entry.MinBid, + builderURL: result.entry.URL, + }) + } + return candidates +} + func (a *ApiHandler) getBuilderPayload( ctx context.Context, baseState *state.CachingBeaconState, @@ -1028,9 +1218,9 @@ func (a *ApiHandler) produceBeaconBody( targetSlot uint64, randaoReveal common.Bytes96, graffiti common.Hash, -) (*cltypes.BeaconBody, uint64, error) { +) (*cltypes.BeaconBody, *big.Int, error) { if targetSlot <= baseBlockSlot { - return nil, 0, fmt.Errorf( + return nil, nil, fmt.Errorf( "target slot %d must be greater than base block slot %d", targetSlot, baseBlockSlot, @@ -1057,10 +1247,10 @@ func (a *ApiHandler) produceBeaconBody( if stateVersion >= clparams.GloasVersion { headNode, err := a.forkchoiceStore.GetHeadNode() if err != nil { - return nil, 0, fmt.Errorf("produceBeaconBody: failed to snapshot fork choice head: %w", err) + return nil, nil, fmt.Errorf("produceBeaconBody: failed to snapshot fork choice head: %w", err) } if err := validateGloasHeadSnapshot(baseBlockRoot, headNode); err != nil { - return nil, 0, err + return nil, nil, err } parentBid := baseState.GetLatestExecutionPayloadBid() if parentBid != nil { @@ -1076,18 +1266,18 @@ func (a *ApiHandler) produceBeaconBody( // Copy state and apply parent execution payload to compute correct withdrawals stateCopy, err := baseState.Copy() if err != nil { - return nil, 0, fmt.Errorf("produceBeaconBody: failed to copy state for FULL payload: %w", err) + return nil, nil, fmt.Errorf("produceBeaconBody: failed to copy state for FULL payload: %w", err) } envelope, err := a.forkchoiceStore.ReadEnvelopeFromDisk(baseBlockRoot) if err != nil { - return nil, 0, fmt.Errorf("produceBeaconBody: failed to read envelope for FULL payload: %w", err) + return nil, nil, fmt.Errorf("produceBeaconBody: failed to read envelope for FULL payload: %w", err) } if envelope == nil || envelope.Message == nil || envelope.Message.ExecutionRequests == nil { - return nil, 0, fmt.Errorf("produceBeaconBody: head is FULL but envelope/requests missing for root %x", baseBlockRoot) + return nil, nil, fmt.Errorf("produceBeaconBody: head is FULL but envelope/requests missing for root %x", baseBlockRoot) } stfMachine := ð2.Impl{} if err := stfMachine.ApplyParentExecutionPayload(stateCopy, envelope.Message.ExecutionRequests); err != nil { - return nil, 0, fmt.Errorf("produceBeaconBody: failed to apply parent execution payload: %w", err) + return nil, nil, fmt.Errorf("produceBeaconBody: failed to apply parent execution payload: %w", err) } gloasWithdrawalsState = stateCopy // Populate the block body's ParentExecutionRequests so @@ -1109,7 +1299,7 @@ func (a *ApiHandler) produceBeaconBody( } proposerIndex, err := baseState.GetBeaconProposerIndexForSlot(targetSlot) if err != nil { - return nil, 0, err + return nil, nil, err } var targetGasLimit *hexutil.Uint64 if stateVersion.AfterOrEqual(clparams.GloasVersion) { @@ -1129,7 +1319,7 @@ func (a *ApiHandler) produceBeaconBody( } } var executionPayload *cltypes.Eth1Block - var executionValue uint64 + executionValue := new(big.Int) // One collector per concurrent body step. Sharing one would be a write-write race whenever // two steps fail together. var executionErr, syncAggregateErr error @@ -1190,9 +1380,9 @@ func (a *ApiHandler) produceBeaconBody( } // Determine block value if blockValue == nil { - executionValue = 0 + executionValue.SetUint64(0) } else { - executionValue = blockValue.Uint64() + executionValue.Set(blockValue) } if stateVersion.Before(clparams.FuluVersion) { @@ -1377,13 +1567,13 @@ func (a *ApiHandler) produceBeaconBody( } wg.Wait() if executionErr != nil { - return nil, 0, executionErr + return nil, nil, executionErr } if syncAggregateErr != nil { - return nil, 0, syncAggregateErr + return nil, nil, syncAggregateErr } if executionPayload == nil { - return nil, 0, errors.New("failed to produce execution payload") + return nil, nil, errors.New("failed to produce execution payload") } if stateVersion.AfterOrEqual(clparams.GloasVersion) { @@ -1554,9 +1744,13 @@ func (a *ApiHandler) setupHeaderReponseForBlockProduction( consensusVersion clparams.StateVersion, blinded bool, executionPayloadIncluded bool, - executionBlockValue, consensusBlockValue uint64, + executionBlockValue *big.Int, + consensusBlockValue uint64, ) { - w.Header().Set("Eth-Execution-Payload-Value", strconv.FormatUint(executionBlockValue, 10)) + if executionBlockValue == nil { + executionBlockValue = new(big.Int) + } + w.Header().Set("Eth-Execution-Payload-Value", executionBlockValue.String()) w.Header().Set("Eth-Consensus-Block-Value", strconv.FormatUint(consensusBlockValue, 10)) w.Header().Set("Eth-Consensus-Version", clparams.ClVersionToString(consensusVersion)) w.Header().Set("Eth-Execution-Payload-Blinded", strconv.FormatBool(blinded)) @@ -1982,29 +2176,13 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac } if kzgCommitments != nil && kzgCommitments.Len() > 0 { - // Build cellsAndProofsPerBlob (common logic) - cellsAndProofsPerBlob := make([]peerdasutils.CellsAndKZGProofs, 0, kzgCommitments.Len()) - for i := 0; i < kzgCommitments.Len(); i++ { - commitment := kzgCommitments.Get(i) - bundle, has := a.blobBundles.Get(common.Bytes48(*commitment)) - if !has { - return fmt.Errorf("missing blob bundle for commitment %x", commitment) - } - cells, err := das.ComputeCells(bundle.Blob) - if err != nil { - return err - } - - cellsAndProof := peerdasutils.CellsAndKZGProofs{} - cellsAndProof.Blobs = append(cellsAndProof.Blobs, cells...) - for j := 0; j < len(bundle.KzgProofs); j++ { - cellsAndProof.Proofs = append(cellsAndProof.Proofs, cltypes.KZGProof(bundle.KzgProofs[j])) - } - cellsAndProofsPerBlob = append(cellsAndProofsPerBlob, cellsAndProof) + cellsAndProofsPerBlob, payloadDataPending, err := collectPublishedPayloadData(kzgCommitments, isGloas, a.blobBundles.Get) + if err != nil { + return err } // Create sidecars based on version - if isGloas { + if isGloas && !payloadDataPending { blockRoot, err := blk.Block.HashSSZ() if err != nil { return fmt.Errorf("failed to compute block root: %w", err) @@ -2013,7 +2191,7 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac if err != nil { return fmt.Errorf("failed to get data column sidecars: %w", err) } - } else { + } else if !isGloas { // Fulu needs inclusion proof inclusionProofRaw, err := blk.Block.Body.KzgCommitmentsInclusionProof() if err != nil { @@ -2095,6 +2273,40 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac return nil } +func collectPublishedPayloadData( + commitments *solid.ListSSZ[*cltypes.KZGCommitment], + isGloas bool, + lookup func(common.Bytes48) (BlobBundle, bool), +) ([]peerdasutils.CellsAndKZGProofs, bool, error) { + cellsAndProofs := make([]peerdasutils.CellsAndKZGProofs, 0, commitments.Len()) + for i := 0; i < commitments.Len(); i++ { + commitment := commitments.Get(i) + if commitment == nil { + return nil, false, fmt.Errorf("missing commitment %d", i) + } + bundle, ok := lookup(common.Bytes48(*commitment)) + if !ok { + if isGloas { + return nil, true, nil + } + return nil, false, fmt.Errorf("missing blob bundle for commitment %x", commitment) + } + if bundle.Blob == nil { + return nil, false, fmt.Errorf("nil blob bundle for commitment %x", commitment) + } + cells, err := das.ComputeCells(bundle.Blob) + if err != nil { + return nil, false, err + } + proofs := make([]cltypes.KZGProof, len(bundle.KzgProofs)) + for i := range proofs { + proofs[i] = cltypes.KZGProof(bundle.KzgProofs[i]) + } + cellsAndProofs = append(cellsAndProofs, peerdasutils.CellsAndKZGProofs{Blobs: cells, Proofs: proofs}) + } + return cellsAndProofs, false, nil +} + // broadcastSelfBuildEnvelope constructs and broadcasts a SignedExecutionPayloadEnvelope // for a self-built GLOAS block. If the validator client provided a signed envelope // (via the block publish request), it is used directly with the real BLS signature. @@ -2210,7 +2422,9 @@ func (a *ApiHandler) storeBlockAndBlobs( if err != nil { return err } - // TODO: write column sidecars if needed + if err := a.storeDataColumnSidecars(ctx, blockRoot, columnSidecars); err != nil { + return err + } if block.Version() < clparams.FuluVersion { if err := a.blobStoage.WriteBlobSidecars(ctx, blockRoot, sidecars); err != nil { @@ -2277,6 +2491,27 @@ func (a *ApiHandler) storeBlockAndBlobs( return nil } +func (a *ApiHandler) storeDataColumnSidecars(ctx context.Context, blockRoot common.Hash, sidecars []*cltypes.DataColumnSidecar) error { + if len(sidecars) == 0 { + return nil + } + if a.columnStorage == nil { + return errors.New("data column storage unavailable") + } + for _, sidecar := range sidecars { + if sidecar == nil { + return errors.New("nil data column sidecar") + } + if sidecar.Index > math.MaxInt64 { + return fmt.Errorf("data column index %d exceeds storage range", sidecar.Index) + } + if err := a.columnStorage.WriteColumnSidecars(ctx, blockRoot, int64(sidecar.Index), sidecar); err != nil { + return err + } + } + return nil +} + func (a *ApiHandler) selectedHeadState(auxiliaryRoot common.Hash) (common.Hash, uint64, *state.CachingBeaconState, error) { auxiliaryState, err := a.forkchoiceStore.GetStateAtBlockRoot(auxiliaryRoot, false) if err != nil { diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index 4990c7f4b89..2da47f32ec2 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -22,6 +22,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "math/big" "net/http" "net/http/httptest" @@ -39,6 +40,7 @@ import ( "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" + blob_storage_mock "github.com/erigontech/erigon/cl/persistence/blob_storage/mock_services" "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/phase1/core/state/lru" "github.com/erigontech/erigon/cl/phase1/execution_client" @@ -60,6 +62,27 @@ import ( "github.com/erigontech/erigon/node/gointerfaces/typesproto" ) +func TestStoreDataColumnSidecars(t *testing.T) { + ctrl := gomock.NewController(t) + storage := blob_storage_mock.NewMockDataColumnStorage(ctrl) + handler := &ApiHandler{columnStorage: storage} + root := common.Hash{1} + column := &cltypes.DataColumnSidecar{Index: 7} + + storage.EXPECT().WriteColumnSidecars(gomock.Any(), root, int64(7), column).Return(nil) + require.NoError(t, handler.storeDataColumnSidecars(context.Background(), root, []*cltypes.DataColumnSidecar{column})) +} + +func TestStoreDataColumnSidecarsRejectsInvalidInput(t *testing.T) { + root := common.Hash{1} + require.NoError(t, (&ApiHandler{}).storeDataColumnSidecars(context.Background(), root, nil)) + require.Error(t, (&ApiHandler{}).storeDataColumnSidecars(context.Background(), root, []*cltypes.DataColumnSidecar{{}})) + require.Error(t, (&ApiHandler{}).storeDataColumnSidecars(context.Background(), root, []*cltypes.DataColumnSidecar{nil})) + require.Error(t, (&ApiHandler{columnStorage: blob_storage_mock.NewMockDataColumnStorage(gomock.NewController(t))}).storeDataColumnSidecars( + context.Background(), root, []*cltypes.DataColumnSidecar{{Index: math.MaxUint64}}, + )) +} + func TestBlockBuilderWindowPreGloas(t *testing.T) { cfg := &clparams.BeaconChainConfig{ SecondsPerSlot: 12, @@ -120,6 +143,182 @@ func TestValidateGloasHeadSnapshotRejectsMismatchedRoot(t *testing.T) { })) } +func TestSelectGloasBidUsesGweiAndBoostSemantics(t *testing.T) { + localWei := big.NewInt(1_500_000_000) + bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 1)} + bid.Message.ExecutionPayment = 1 + + selected := selectGloasBid(localWei, []gloasBidCandidate{{ + bid: bid, + boostFactor: 100, + maxExecutionPayment: 1, + }}) + + require.NotNil(t, selected) + require.Same(t, bid, selected.bid) + require.Equal(t, "2000000000", selected.executionValueWei.String()) +} + +func TestSelectGloasBidLocalWinsTieAndZeroBoost(t *testing.T) { + bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 2)} + + require.Nil(t, selectGloasBid(big.NewInt(2_000_000_000), []gloasBidCandidate{{ + bid: bid, + boostFactor: 100, + maxExecutionPayment: 0, + }})) + require.Nil(t, selectGloasBid(big.NewInt(1), []gloasBidCandidate{{ + bid: bid, + boostFactor: 0, + maxExecutionPayment: 0, + }})) +} + +func TestSelectGloasBidCapsExecutionPaymentAndAvoidsOverflow(t *testing.T) { + capped := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 1)} + capped.Message.ExecutionPayment = 100 + overflow := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 2, ^uint64(0))} + overflow.Message.ExecutionPayment = ^uint64(0) + + selected := selectGloasBid(big.NewInt(2_500_000_000), []gloasBidCandidate{ + {bid: capped, boostFactor: 100, maxExecutionPayment: 1}, + {bid: overflow, boostFactor: 1, maxExecutionPayment: ^uint64(0)}, + }) + + require.NotNil(t, selected) + require.Same(t, overflow, selected.bid) + require.Equal(t, new(big.Int).Mul(new(big.Int).SetUint64(^uint64(0)), big.NewInt(1_000_000_000)), selected.executionValueWei) +} + +func TestSelectGloasBidAppliesMinimumBidToCappedValue(t *testing.T) { + bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 5)} + bid.Message.ExecutionPayment = 10 + require.Nil(t, selectGloasBid(new(big.Int), []gloasBidCandidate{{ + bid: bid, boostFactor: 100, maxExecutionPayment: 2, minBid: 8, + }})) + require.NotNil(t, selectGloasBid(new(big.Int), []gloasBidCandidate{{ + bid: bid, boostFactor: 100, maxExecutionPayment: 3, minBid: 8, + }})) +} + +func TestDecodeGloasBlockProductionOptionsJSONAndSSZ(t *testing.T) { + config := &cltypes.BuilderConfig{ + MinBid: 4, + BuilderBoostFactor: 125, + Builders: []*cltypes.BuilderEntry{{ + URL: "https://builder.example", + Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{ + Data: []byte("https://builder.example"), Slot: 10, + }}, + BuilderPubkeys: []common.Bytes48{{1}}, + MaxExecutionPayment: 5, + MinBid: 6, + BuilderBoostFactor: 150, + }}, + } + jsonBody, err := json.Marshal(config) + require.NoError(t, err) + sszBody, err := config.EncodeSSZ(nil) + require.NoError(t, err) + + for _, tc := range []struct { + name string + contentType string + body []byte + }{ + {name: "json", contentType: "application/json", body: jsonBody}, + {name: "ssz", contentType: "application/octet-stream", body: sszBody}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10?include_payload=true", bytes.NewReader(tc.body)) + req.Header.Set("Content-Type", tc.contentType) + req.Header.Set("Eth-Consensus-Version", "gloas") + opts, err := decodeGloasBlockProductionOptions(httptest.NewRecorder(), req, 10) + require.NoError(t, err) + require.True(t, opts.includePayload) + require.Equal(t, config.MinBid, opts.builderConfig.MinBid) + require.Equal(t, config.Builders[0].URL, opts.builderConfig.Builders[0].URL) + }) + } +} + +func TestDecodeGloasBlockProductionOptionsRejectsInvalidMetadata(t *testing.T) { + valid := `{"min_bid":"0","builder_boost_factor":"100","builders":[]}` + for _, tc := range []struct { + name string + url string + version string + body string + }{ + {name: "missing include payload", url: "/eth/v4/validator/blocks/10", version: "gloas", body: valid}, + {name: "invalid include payload", url: "/eth/v4/validator/blocks/10?include_payload=sure", version: "gloas", body: valid}, + {name: "missing version", url: "/eth/v4/validator/blocks/10?include_payload=true", body: valid}, + {name: "wrong version", url: "/eth/v4/validator/blocks/10?include_payload=true", version: "fulu", body: valid}, + {name: "missing body", url: "/eth/v4/validator/blocks/10?include_payload=true", version: "gloas"}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, tc.url, strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + if tc.version != "" { + req.Header.Set("Eth-Consensus-Version", tc.version) + } + _, err := decodeGloasBlockProductionOptions(httptest.NewRecorder(), req, 10) + require.Error(t, err) + }) + } +} + +func TestDecodeGloasBlockProductionOptionsAcceptsMaximumJSONConfig(t *testing.T) { + config := &cltypes.BuilderConfig{Builders: make([]*cltypes.BuilderEntry, cltypes.MaxBuilderEntries)} + for i := range config.Builders { + authData := make([]byte, cltypes.MaxBuilderAuthDataSize) + authData[0] = byte(i) + config.Builders[i] = &cltypes.BuilderEntry{ + URL: "https://example.com/" + strings.Repeat("a", 2020) + fmt.Sprintf("%02d", i), + Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{Data: authData, Slot: 10}}, + BuilderPubkeys: make([]common.Bytes48, cltypes.MaxBuilderPubkeys), + } + } + body, err := json.Marshal(config) + require.NoError(t, err) + require.Greater(t, len(body), 1<<20) + require.LessOrEqual(t, len(body), maxBuilderConfigRequestSize) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10?include_payload=false", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Eth-Consensus-Version", "gloas") + _, err = decodeGloasBlockProductionOptions(httptest.NewRecorder(), req, 10) + require.NoError(t, err) +} + +func TestPostV4WithBidRouteRejectsMissingVersionBeforeProduction(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10/with_bid", strings.NewReader(`{}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) +} + +func TestPostV4WithBidRejectsTrailingJSON(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 0 + bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 1)} + body, err := json.Marshal(bid) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10/with_bid", strings.NewReader(string(body)+`{}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), "trailing data") +} + func TestPublishBlindedBlocksRejectsGloas(t *testing.T) { _, _, _, _, _, h, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blinded_blocks", bytes.NewReader(nil)) @@ -558,7 +757,7 @@ func TestSetupHeaderResponseForBlockProductionGloasPayloadIncluded(t *testing.T) h := &ApiHandler{} rr := httptest.NewRecorder() - h.setupHeaderReponseForBlockProduction(rr, clparams.GloasVersion, false, true, 123, 456) + h.setupHeaderReponseForBlockProduction(rr, clparams.GloasVersion, false, true, big.NewInt(123), 456) require.Equal(t, "gloas", rr.Header().Get("Eth-Consensus-Version")) require.Equal(t, "123", rr.Header().Get("Eth-Execution-Payload-Value")) @@ -571,11 +770,35 @@ func TestSetupHeaderResponseForBlockProductionPreGloasOmitsPayloadIncluded(t *te h := &ApiHandler{} rr := httptest.NewRecorder() - h.setupHeaderReponseForBlockProduction(rr, clparams.ElectraVersion, false, true, 123, 456) + h.setupHeaderReponseForBlockProduction(rr, clparams.ElectraVersion, false, true, big.NewInt(123), 456) require.Empty(t, rr.Header().Get("Eth-Execution-Payload-Included")) } +func TestSetupHeaderResponsePreservesExecutionValueAboveUint64(t *testing.T) { + h := &ApiHandler{} + rr := httptest.NewRecorder() + value := new(big.Int).Lsh(big.NewInt(1), 80) + h.setupHeaderReponseForBlockProduction(rr, clparams.GloasVersion, false, false, value, 0) + require.Equal(t, value.String(), rr.Header().Get("Eth-Execution-Payload-Value")) +} + +func TestBroadcastGloasBuilderBlockDoesNotRequireEnvelopeBlobsYet(t *testing.T) { + commitments := solid.NewStaticListSSZ[*cltypes.KZGCommitment](cltypes.MaxBlobsCommittmentsPerBlock, 48) + commitment := cltypes.KZGCommitment{1} + commitments.Append(&commitment) + columns, pending, err := collectPublishedPayloadData(commitments, true, func(common.Bytes48) (BlobBundle, bool) { + return BlobBundle{}, false + }) + require.NoError(t, err) + require.True(t, pending) + require.Empty(t, columns) + _, _, err = collectPublishedPayloadData(commitments, false, func(common.Bytes48) (BlobBundle, bool) { + return BlobBundle{}, false + }) + require.Error(t, err) +} + // TestCaplinBlockProductionWithWithdrawalRequest tests Caplin's produceBeaconBody // against a real Erigon execution layer. A withdrawal request transaction is // submitted to the EIP-7002 system contract, and then Caplin's actual block diff --git a/cl/beacon/handler/block_production_v4.go b/cl/beacon/handler/block_production_v4.go new file mode 100644 index 00000000000..45b05182d71 --- /dev/null +++ b/cl/beacon/handler/block_production_v4.go @@ -0,0 +1,180 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/erigontech/erigon/cl/beacon/beaconhttp" + "github.com/erigontech/erigon/cl/clparams" + "github.com/erigontech/erigon/cl/cltypes" +) + +const maxBuilderConfigRequestSize = 2 << 20 + +type gloasBlockProductionOptions struct { + builderConfig *cltypes.BuilderConfig + includePayload bool + suppliedBid *cltypes.SignedExecutionPayloadBid + selectedBuilderURL string +} + +type gloasBlockProductionOptionsKey struct{} + +func decodeGloasBlockProductionOptions(w http.ResponseWriter, r *http.Request, targetSlot uint64) (*gloasBlockProductionOptions, error) { + version := r.Header.Get("Eth-Consensus-Version") + if version == "" { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Eth-Consensus-Version header is required")) + } + parsedVersion, err := clparams.StringToClVersion(version) + if err != nil || parsedVersion != clparams.GloasVersion { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("v4 block production requires gloas consensus version")) + } + includePayloadValue := r.URL.Query().Get("include_payload") + if includePayloadValue == "" { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("include_payload query parameter is required")) + } + includePayload, err := strconv.ParseBool(includePayloadValue) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid include_payload: %w", err)) + } + config := new(cltypes.BuilderConfig) + contentType, err := requestContentType(r) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, err) + } + switch contentType { + case "application/json": + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBuilderConfigRequestSize)) + if err := decoder.Decode(config); err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("request body contains trailing data")) + } + case "application/octet-stream": + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBuilderConfigRequestSize)) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } + if err := config.DecodeSSZStrict(body, int(clparams.GloasVersion)); err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } + default: + return nil, beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, fmt.Errorf("unsupported content type: %s", contentType)) + } + for i, entry := range config.Builders { + if entry == nil || entry.Auth == nil || entry.Auth.Message == nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("builder %d has invalid auth", i)) + } + if entry.Auth.Message.Slot != targetSlot { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("builder %d auth slot %d does not match proposal slot %d", i, entry.Auth.Message.Slot, targetSlot)) + } + } + return &gloasBlockProductionOptions{builderConfig: config, includePayload: includePayload}, nil +} + +func (a *ApiHandler) PostEthV4ValidatorBlock(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { + targetSlot, err := strconv.ParseUint(chi.URLParam(r, "slot"), 10, 64) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid slot: %w", err)) + } + if a.beaconChainCfg.SlotsPerEpoch == 0 || targetSlot/a.beaconChainCfg.SlotsPerEpoch < a.beaconChainCfg.GloasForkEpoch { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("v4 block production is unavailable before Gloas")) + } + options, err := decodeGloasBlockProductionOptions(w, r, targetSlot) + if err != nil { + return nil, err + } + r = r.WithContext(context.WithValue(r.Context(), gloasBlockProductionOptionsKey{}, options)) + return a.GetEthV3ValidatorBlock(w, r) +} + +func (a *ApiHandler) PostEthV4ValidatorBlockWithBid(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { + targetSlot, err := strconv.ParseUint(chi.URLParam(r, "slot"), 10, 64) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid slot: %w", err)) + } + if a.beaconChainCfg.SlotsPerEpoch == 0 || targetSlot/a.beaconChainCfg.SlotsPerEpoch < a.beaconChainCfg.GloasForkEpoch { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("v4 block production is unavailable before Gloas")) + } + if r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")) + } + includePayload := false + if value := r.URL.Query().Get("include_payload"); value != "" { + includePayload, err = strconv.ParseBool(value) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid include_payload: %w", err)) + } + } + boost := uint64(100) + if value := r.URL.Query().Get("builder_boost_factor"); value != "" { + boost, err = strconv.ParseUint(value, 10, 64) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid builder_boost_factor: %w", err)) + } + } + bid := new(cltypes.SignedExecutionPayloadBid) + contentType, err := requestContentType(r) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, err) + } + switch contentType { + case "application/json": + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxEpbsJSONSize)) + if err := decoder.Decode(bid); err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("request body contains trailing data")) + } + case "application/octet-stream": + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxSignedExecutionPayloadBidSSZSize())) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } + if err := bid.DecodeSSZ(body, int(clparams.GloasVersion)); err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } + default: + return nil, beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, fmt.Errorf("unsupported content type: %s", contentType)) + } + if bid.Message == nil || bid.Message.Slot != targetSlot { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("execution payload bid slot does not match proposal slot")) + } + options := &gloasBlockProductionOptions{ + builderConfig: &cltypes.BuilderConfig{BuilderBoostFactor: boost}, + includePayload: includePayload, + suppliedBid: bid, + } + r = r.WithContext(context.WithValue(r.Context(), gloasBlockProductionOptionsKey{}, options)) + return a.GetEthV3ValidatorBlock(w, r) +} + +func gloasBlockOptionsFromContext(ctx context.Context) *gloasBlockProductionOptions { + options, _ := ctx.Value(gloasBlockProductionOptionsKey{}).(*gloasBlockProductionOptions) + return options +} diff --git a/cl/beacon/handler/builder.go b/cl/beacon/handler/builder.go index 84d059f98ff..6ea247549c0 100644 --- a/cl/beacon/handler/builder.go +++ b/cl/beacon/handler/builder.go @@ -19,17 +19,238 @@ package handler import ( "encoding/json" "errors" + "fmt" + "io" + "math" "net/http" + "strconv" "github.com/erigontech/erigon/cl/beacon/beaconhttp" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/persistence/beacon_indicies" "github.com/erigontech/erigon/cl/phase1/core/state" + "github.com/erigontech/erigon/cl/phase1/forkchoice/fork_graph" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" ) +const maxStateBuildersRequestSize = 1 << 20 + +type stateBuildersRequest struct { + Ids []string `json:"ids"` + Statuses []string `json:"statuses"` +} + +type stateBuilderResponse struct { + Index string `json:"index"` + Status string `json:"status"` + Builder stateBuilderAPIResponse `json:"builder"` +} + +type stateBuilderAPIResponse struct { + Pubkey common.Bytes48 `json:"pubkey"` + Version string `json:"version"` + ExecutionAddress common.Address `json:"execution_address"` + Balance string `json:"balance"` + DepositEpoch string `json:"deposit_epoch"` + WithdrawableEpoch string `json:"withdrawable_epoch"` +} + +func decodeStateBuildersRequest(w http.ResponseWriter, r *http.Request) (stateBuildersRequest, error) { + request := new(stateBuildersRequest) + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxStateBuildersRequestSize)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil && !errors.Is(err, io.EOF) { + return stateBuildersRequest{}, err + } + if request == nil { + return stateBuildersRequest{}, errors.New("request body must be a JSON object") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return stateBuildersRequest{}, errors.New("request body must contain one JSON object") + } + return *request, nil +} + +func parseStateBuilderFilters(request stateBuildersRequest) (map[uint64]struct{}, map[common.Bytes48]struct{}, map[string]struct{}, error) { + indices := make(map[uint64]struct{}) + pubkeys := make(map[common.Bytes48]struct{}) + for _, id := range request.Ids { + if len(id) >= 2 && id[:2] == "0x" { + var pubkey common.Bytes48 + if err := pubkey.UnmarshalText([]byte(id)); err != nil { + return nil, nil, nil, fmt.Errorf("invalid builder id %q: %w", id, err) + } + if _, duplicate := pubkeys[pubkey]; duplicate { + return nil, nil, nil, fmt.Errorf("duplicate builder id %q", id) + } + pubkeys[pubkey] = struct{}{} + continue + } + index, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid builder id %q: %w", id, err) + } + if _, duplicate := indices[index]; duplicate { + return nil, nil, nil, fmt.Errorf("duplicate builder id %q", id) + } + indices[index] = struct{}{} + } + statuses := make(map[string]struct{}) + for _, status := range request.Statuses { + switch status { + case "pending", "active", "exited": + default: + return nil, nil, nil, fmt.Errorf("invalid builder status %q", status) + } + if _, duplicate := statuses[status]; duplicate { + return nil, nil, nil, fmt.Errorf("duplicate builder status %q", status) + } + statuses[status] = struct{}{} + } + return indices, pubkeys, statuses, nil +} + +func stateBuilderStatus(builder *cltypes.Builder, finalizedEpoch uint64) string { + if builder.WithdrawableEpoch != math.MaxUint64 { + return "exited" + } + if builder.DepositEpoch < finalizedEpoch { + return "active" + } + return "pending" +} + +func stateBuildersResponse(s *state.CachingBeaconState, indices map[uint64]struct{}, pubkeys map[common.Bytes48]struct{}, statuses map[string]struct{}) ([]stateBuilderResponse, error) { + if s.Version() < clparams.GloasVersion { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("the specified state is not a gloas state")) + } + builders := s.GetBuilders() + if builders == nil { + return nil, errors.New("builder registry is unavailable") + } + responseCapacity := builders.Len() + if selected := len(indices) + len(pubkeys); selected > 0 && selected < responseCapacity { + responseCapacity = selected + } + responses := make([]stateBuilderResponse, 0, responseCapacity) + var registryErr error + builders.Range(func(index int, builder *cltypes.Builder, _ int) bool { + if builder == nil { + registryErr = errors.New("builder registry contains a nil builder") + return false + } + if len(indices)+len(pubkeys) > 0 { + _, indexSelected := indices[uint64(index)] + _, pubkeySelected := pubkeys[builder.Pubkey] + if !indexSelected && !pubkeySelected { + return true + } + } + status := stateBuilderStatus(builder, s.FinalizedCheckpoint().Epoch) + if len(statuses) > 0 { + if _, selected := statuses[status]; !selected { + return true + } + } + responses = append(responses, stateBuilderResponse{ + Index: strconv.FormatUint(uint64(index), 10), + Status: status, + Builder: stateBuilderAPIResponse{ + Pubkey: builder.Pubkey, + Version: strconv.FormatUint(uint64(builder.Version), 10), + ExecutionAddress: builder.ExecutionAddress, + Balance: strconv.FormatUint(builder.Balance, 10), + DepositEpoch: strconv.FormatUint(builder.DepositEpoch, 10), + WithdrawableEpoch: strconv.FormatUint(builder.WithdrawableEpoch, 10), + }, + }) + return true + }) + if registryErr != nil { + return nil, registryErr + } + return responses, nil +} + +func (a *ApiHandler) PostEthV1BeaconStatesBuilders(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { + request, err := decodeStateBuildersRequest(w, r) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } + indices, pubkeys, statuses, err := parseStateBuilderFilters(request) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } + stateID, err := beaconhttp.StateIdFromRequest(r) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } + if stateID.Head() { + var response *beaconhttp.BeaconResponse + err := a.viewHeadStateWithIdentity(func(headState *state.CachingBeaconState, root common.Hash, slot uint64) error { + data, err := stateBuildersResponse(headState, indices, pubkeys, statuses) + if err != nil { + return err + } + response = newBeaconResponse(data). + WithOptimistic(a.forkchoiceStore.IsRootOptimistic(root)). + WithFinalized(slot <= a.forkchoiceStore.FinalizedSlot()) + return nil + }) + return response, err + } + + tx, err := a.indiciesDB.BeginRo(r.Context()) + if err != nil { + return nil, err + } + defer tx.Rollback() + root, statusCode, err := a.blockRootFromStateId(r.Context(), tx, stateID) + if err != nil { + return nil, beaconhttp.NewEndpointError(statusCode, err) + } + slot, err := beacon_indicies.ReadBlockSlotByBlockRoot(tx, root) + if err != nil { + return nil, err + } + if slot == nil { + return nil, beaconhttp.NewEndpointError(http.StatusNotFound, errors.New("state not found")) + } + requestedState, err := a.forkchoiceStore.GetStateAtBlockRoot(root, true) + if err != nil && !errors.Is(err, fork_graph.ErrStateNotFound) { + return nil, err + } + if requestedState == nil { + canonicalRoot, err := beacon_indicies.ReadCanonicalBlockRoot(tx, *slot) + if err != nil { + return nil, err + } + if canonicalRoot != root { + return nil, beaconhttp.NewEndpointError(http.StatusNotFound, errors.New("state not found")) + } + requestedState, err = a.stateReader.ReadHistoricalState(r.Context(), tx, *slot) + if err != nil { + return nil, err + } + if requestedState == nil { + return nil, beaconhttp.NewEndpointError(http.StatusNotFound, errors.New("state not found")) + } + } + data, err := stateBuildersResponse(requestedState, indices, pubkeys, statuses) + if err != nil { + return nil, err + } + canonicalRoot, err := beacon_indicies.ReadCanonicalBlockRoot(tx, *slot) + if err != nil { + return nil, err + } + return newBeaconResponse(data). + WithOptimistic(a.forkchoiceStore.IsRootOptimistic(root)). + WithFinalized(canonicalRoot == root && *slot <= a.forkchoiceStore.FinalizedSlot()), nil +} + func (a *ApiHandler) GetEth1V1BuilderStatesExpectedWithdrawals(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { ctx := r.Context() diff --git a/cl/beacon/handler/builder_preferences.go b/cl/beacon/handler/builder_preferences.go new file mode 100644 index 00000000000..2627b019963 --- /dev/null +++ b/cl/beacon/handler/builder_preferences.go @@ -0,0 +1,90 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/erigontech/erigon/cl/beacon/beaconhttp" + "github.com/erigontech/erigon/cl/clparams" + "github.com/erigontech/erigon/cl/cltypes" +) + +const maxBuilderPreferencesRequestSize = 64 << 20 + +func (a *ApiHandler) PostEthV1ValidatorBuilderPreferences(w http.ResponseWriter, r *http.Request) { + version, err := clparams.StringToClVersion(r.Header.Get("Eth-Consensus-Version")) + if err != nil || version != clparams.GloasVersion { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")).WriteTo(w) + return + } + if a.builderClient == nil { + beaconhttp.NewEndpointError(http.StatusServiceUnavailable, errors.New("builder client is unavailable")).WriteTo(w) + return + } + entries := cltypes.BuilderPreferencesEntries{} + contentType, err := requestContentType(r) + if err != nil { + beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, err).WriteTo(w) + return + } + switch contentType { + case "application/json": + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBuilderPreferencesRequestSize)) + if err := decoder.Decode(&entries); err != nil { + beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + return + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("request body contains trailing data")).WriteTo(w) + return + } + case "application/octet-stream": + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBuilderPreferencesRequestSize)) + if err != nil { + beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + return + } + if err := entries.DecodeSSZStrict(body, int(clparams.GloasVersion)); err != nil { + beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + return + } + default: + beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, fmt.Errorf("unsupported content type: %s", contentType)).WriteTo(w) + return + } + + failures := make([]poolingFailure, 0) + for i, entry := range entries { + request := &cltypes.BuilderPreferencesRequest{ + Preferences: &cltypes.BuilderPreferences{MaxExecutionPayment: entry.MaxExecutionPayment}, + Auth: entry.Auth, + } + if err := a.builderClient.SubmitBuilderPreferences(r.Context(), entry.URL, entry.ProposerPubkey, request); err != nil { + failures = append(failures, poolingFailure{Index: i, Message: err.Error()}) + } + } + if len(failures) != 0 { + a.writePoolingFailures(w, failures) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/cl/beacon/handler/builder_preferences_test.go b/cl/beacon/handler/builder_preferences_test.go new file mode 100644 index 00000000000..121354173bd --- /dev/null +++ b/cl/beacon/handler/builder_preferences_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package handler + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/erigontech/erigon/cl/beacon/builder/mock_services" + "github.com/erigontech/erigon/cl/cltypes" + "github.com/erigontech/erigon/common" +) + +func testBuilderPreferencesEntries() cltypes.BuilderPreferencesEntries { + entries := make(cltypes.BuilderPreferencesEntries, 2) + for i := range entries { + entries[i] = &cltypes.BuilderPreferencesEntry{ + ProposerPubkey: common.Bytes48{byte(i + 1)}, + URL: "https://builder.example", + Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{ + Data: []byte("https://builder.example"), Slot: 10, + }}, + MaxExecutionPayment: uint64(i + 1), + } + } + return entries +} + +func TestPostValidatorBuilderPreferencesReportsPartialFailuresAfterSubmittingAll(t *testing.T) { + ctrl := gomock.NewController(t) + client := mock_services.NewMockBuilderClient(ctrl) + entries := testBuilderPreferencesEntries() + client.EXPECT().SubmitBuilderPreferences(gomock.Any(), entries[0].URL, entries[0].ProposerPubkey, gomock.Any()).Return(errors.New("first failed")) + client.EXPECT().SubmitBuilderPreferences(gomock.Any(), entries[1].URL, entries[1].ProposerPubkey, gomock.Any()).Return(nil) + handler := &ApiHandler{builderClient: client} + body, err := entries.MarshalJSON() + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/builder_preferences", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1ValidatorBuilderPreferences(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, recorder.Body.String(), `"index":0`) +} + +func TestPostValidatorBuilderPreferencesAcceptsSSZ(t *testing.T) { + ctrl := gomock.NewController(t) + client := mock_services.NewMockBuilderClient(ctrl) + entries := testBuilderPreferencesEntries()[:1] + client.EXPECT().SubmitBuilderPreferences(gomock.Any(), entries[0].URL, entries[0].ProposerPubkey, gomock.Any()).Return(nil) + handler := &ApiHandler{builderClient: client} + body, err := entries.EncodeSSZ(nil) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/builder_preferences", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1ValidatorBuilderPreferences(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) +} + +func TestPostValidatorBuilderPreferencesAcceptsMaximumJSONList(t *testing.T) { + ctrl := gomock.NewController(t) + client := mock_services.NewMockBuilderClient(ctrl) + url := "https://builder.example/" + strings.Repeat("a", 2000) + entry := &cltypes.BuilderPreferencesEntry{ + ProposerPubkey: common.Bytes48{1}, + URL: url, + Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{ + Data: make([]byte, cltypes.MaxBuilderAuthDataSize), Slot: 10, + }}, + } + entries := make(cltypes.BuilderPreferencesEntries, cltypes.MaxBuilderPreferencesEntries) + for i := range entries { + entries[i] = entry + } + body, err := entries.MarshalJSON() + require.NoError(t, err) + require.Greater(t, len(body), 32<<20) + client.EXPECT().SubmitBuilderPreferences(gomock.Any(), url, entry.ProposerPubkey, gomock.Any()).Return(nil).Times(len(entries)) + handler := &ApiHandler{builderClient: client} + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/builder_preferences", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1ValidatorBuilderPreferences(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) +} diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index 23bb79fc889..47abe67c31e 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -19,6 +19,7 @@ package handler import ( "bytes" "cmp" + "context" "encoding/json" "errors" "fmt" @@ -27,11 +28,15 @@ import ( "net/http" "slices" "strconv" + "strings" + "github.com/erigontech/erigon/cl/beacon/beaconevents" "github.com/erigontech/erigon/cl/beacon/beaconhttp" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" + "github.com/erigontech/erigon/cl/das" + peerdasutils "github.com/erigontech/erigon/cl/das/utils" "github.com/erigontech/erigon/cl/gossip" "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/phase1/forkchoice" @@ -776,34 +781,32 @@ func (a *ApiHandler) GetEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrite // The envelope is processed through forkchoice and broadcast on gossip. // [New in Gloas:EIP7732] func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWriter, r *http.Request) { - signedEnvelope := &cltypes.SignedExecutionPayloadEnvelope{ - Message: cltypes.NewExecutionPayloadEnvelope(a.beaconChainCfg), - } - contentType, err := requestContentType(r) if err != nil { beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, err).WriteTo(w) return } - switch contentType { - case "application/json": - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxExecutionPayloadEnvelopeRequestSize)).Decode(signedEnvelope); err != nil { - beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + canonical := strings.Contains(r.URL.Path, "/execution_payload_envelopes") + blobDataIncluded := false + if canonical { + if r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")).WriteTo(w) return } - case "application/octet-stream": - octect, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxExecutionPayloadEnvelopeRequestSize)) - if err != nil { - beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + value := r.Header.Get("Eth-Blob-Data-Included") + if value == "" { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Eth-Blob-Data-Included header is required")).WriteTo(w) return } - if err := signedEnvelope.DecodeSSZ(octect, int(clparams.GloasVersion)); err != nil { - beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + blobDataIncluded, err = strconv.ParseBool(value) + if err != nil { + beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid Eth-Blob-Data-Included: %w", err)).WriteTo(w) return } - default: - beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, - fmt.Errorf("unsupported content type: %s", r.Header.Get("Content-Type"))).WriteTo(w) + } + signedEnvelope, contents, err := a.decodeExecutionPayloadEnvelopeRequest(w, r, contentType, blobDataIncluded) + if err != nil { + beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) return } @@ -811,18 +814,37 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("missing message in signed envelope")).WriteTo(w) return } + if contents != nil { + if err := a.storeExecutionPayloadEnvelopeContents(r.Context(), contents); err != nil { + beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + return + } + } - // Process through forkchoice so the local node marks the block as FULL. - // checkBlobData=false because gossip validation handles it; validatePayload=true - // so the EL receives NewPayload for the execution payload. - if err := a.forkchoiceStore.OnExecutionPayload(r.Context(), signedEnvelope, false, true); err != nil { + status := http.StatusOK + if err := a.forkchoiceStore.OnExecutionPayload(r.Context(), signedEnvelope, canonical, true); err != nil { if errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) { a.logger.Debug("[Beacon REST] OnExecutionPayload queued or ignored", "err", err) + status = http.StatusAccepted } else { beaconhttp.WrapEndpointError(err).WriteTo(w) return } } + if status == http.StatusOK && a.emitters != nil { + block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot) + if ok && block != nil && block.Block != nil && signedEnvelope.Message.Payload != nil { + a.emitters.Operation().SendExecutionPayload(&beaconevents.ExecutionPayloadData{ + Slot: block.Block.Slot, BuilderIndex: signedEnvelope.Message.BuilderIndex, + BlockHash: signedEnvelope.Message.Payload.BlockHash, BlockRoot: signedEnvelope.Message.BeaconBlockRoot, + ExecutionOptimistic: a.forkchoiceStore.IsRootOptimistic(signedEnvelope.Message.BeaconBlockRoot), + }) + a.emitters.Operation().SendExecutionPayloadAvailable(&beaconevents.ExecutionPayloadAvailableData{ + Slot: block.Block.Slot, BlockRoot: signedEnvelope.Message.BeaconBlockRoot, + }) + a.emitFullHeadV2(block, signedEnvelope.Message.BeaconBlockRoot) + } + } // Broadcast the envelope on the execution_payload gossip topic if a.sentinel != nil { @@ -836,7 +858,154 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit } } - w.WriteHeader(http.StatusOK) + w.WriteHeader(status) +} + +func (a *ApiHandler) emitFullHeadV2(block *cltypes.SignedBeaconBlock, blockRoot common.Hash) { + headRoot, headSlot, err := a.forkchoiceStore.GetHead(nil) + if err != nil || headRoot != blockRoot || a.beaconChainCfg.SlotsPerEpoch == 0 { + return + } + headState, err := a.forkchoiceStore.GetStateAtBlockRoot(blockRoot, true) + if err != nil || headState == nil { + return + } + epoch := headSlot / a.beaconChainCfg.SlotsPerEpoch + currentRoot, nextRoot := a.forkchoiceStore.AnchorRoot(), a.forkchoiceStore.AnchorRoot() + if epoch > 1 { + currentRoot, err = headState.GetBlockRootAtSlot((epoch-1)*a.beaconChainCfg.SlotsPerEpoch - 1) + if err != nil { + return + } + } + if epoch > 0 { + nextRoot, err = headState.GetBlockRootAtSlot(epoch*a.beaconChainCfg.SlotsPerEpoch - 1) + if err != nil { + return + } + } + a.emitters.State().SendHeadV2(&beaconevents.HeadV2Data{ + Version: clparams.GloasVersion.String(), + Data: beaconevents.HeadV2Content{ + Slot: headSlot, Block: headRoot, State: block.Block.StateRoot, PayloadStatus: "full", + EpochTransition: headSlot%a.beaconChainCfg.SlotsPerEpoch == 0, CurrentEpochDependentRoot: currentRoot, + NextEpochDependentRoot: nextRoot, ExecutionOptimistic: a.forkchoiceStore.IsRootOptimistic(blockRoot), + }, + }) +} + +func (a *ApiHandler) decodeExecutionPayloadEnvelopeRequest(w http.ResponseWriter, r *http.Request, contentType string, blobDataIncluded bool) (*cltypes.SignedExecutionPayloadEnvelope, *cltypes.SignedExecutionPayloadEnvelopeContents, error) { + signedEnvelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(a.beaconChainCfg)} + var contents *cltypes.SignedExecutionPayloadEnvelopeContents + if blobDataIncluded { + contents = cltypes.NewSignedExecutionPayloadEnvelopeContents(a.beaconChainCfg, a.ethClock.GetCurrentSlot()) + } + switch contentType { + case "application/json": + target := any(signedEnvelope) + if contents != nil { + target = contents + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxExecutionPayloadEnvelopeRequestSize)) + if err := decoder.Decode(target); err != nil { + return nil, nil, err + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return nil, nil, errors.New("request body contains trailing data") + } + case "application/octet-stream": + octets, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxExecutionPayloadEnvelopeRequestSize)) + if err != nil { + return nil, nil, err + } + if contents != nil { + if err := contents.DecodeSSZStrict(octets, int(clparams.GloasVersion)); err != nil { + return nil, nil, err + } + } else if err := signedEnvelope.DecodeSSZStrict(octets, int(clparams.GloasVersion)); err != nil { + return nil, nil, err + } + default: + return nil, nil, fmt.Errorf("unsupported content type: %s", r.Header.Get("Content-Type")) + } + if contents != nil { + signedEnvelope = contents.SignedExecutionPayloadEnvelope + } + return signedEnvelope, contents, nil +} + +func (a *ApiHandler) storeExecutionPayloadEnvelopeContents(ctx context.Context, contents *cltypes.SignedExecutionPayloadEnvelopeContents) error { + if contents == nil || contents.SignedExecutionPayloadEnvelope == nil || contents.SignedExecutionPayloadEnvelope.Message == nil { + return errors.New("execution payload envelope contents has nil envelope") + } + envelope := contents.SignedExecutionPayloadEnvelope.Message + block, ok := a.forkchoiceStore.GetBlock(envelope.BeaconBlockRoot) + if !ok || block == nil || block.Block == nil || block.Block.Body == nil { + return errors.New("execution payload envelope references an unknown block") + } + bid := block.Block.Body.GetSignedExecutionPayloadBid() + if bid == nil || bid.Message == nil { + return errors.New("execution payload envelope block has no bid") + } + commitments := &bid.Message.BlobKzgCommitments + if contents.Blobs == nil || contents.KZGProofs == nil || contents.Blobs.Len() != commitments.Len() || + contents.KZGProofs.Len() != commitments.Len()*int(a.beaconChainCfg.NumberOfColumns) { + return errors.New("execution payload envelope blob, proof, and commitment counts do not match") + } + cellsAndProofs := make([]peerdasutils.CellsAndKZGProofs, 0, commitments.Len()) + bundles := make([]BlobBundle, 0, commitments.Len()) + for i := 0; i < commitments.Len(); i++ { + blob := contents.Blobs.Get(i) + commitment := commitments.Get(i) + if blob == nil || commitment == nil { + return fmt.Errorf("execution payload envelope blob %d is nil", i) + } + bundle := BlobBundle{Blob: blob, Commitment: common.Bytes48(*commitment), KzgProofs: make([]common.Bytes48, a.beaconChainCfg.NumberOfColumns)} + cells, err := das.ComputeCells(blob) + if err != nil { + return err + } + proofs := make([]cltypes.KZGProof, a.beaconChainCfg.NumberOfColumns) + for j := range proofs { + proof := contents.KZGProofs.Get(i*int(a.beaconChainCfg.NumberOfColumns) + j) + if proof == nil { + return fmt.Errorf("execution payload envelope proof %d is nil", j) + } + proofs[j] = *proof + bundle.KzgProofs[j] = common.Bytes48(*proof) + } + bundles = append(bundles, bundle) + cellsAndProofs = append(cellsAndProofs, peerdasutils.CellsAndKZGProofs{Blobs: cells, Proofs: proofs}) + } + columns, err := peerdasutils.GetDataColumnSidecarsGloas(block.Block.Slot, envelope.BeaconBlockRoot, cellsAndProofs) + if err != nil { + return err + } + for _, column := range columns { + if !das.VerifyDataColumnSidecarKZGProofsWithCommitments(column, commitments) { + return fmt.Errorf("execution payload envelope column %d has invalid KZG proof", column.Index) + } + } + for _, bundle := range bundles { + a.blobBundles.Add(bundle.Commitment, bundle) + } + for _, column := range columns { + if a.columnStorage != nil { + if err := a.columnStorage.WriteColumnSidecars(ctx, envelope.BeaconBlockRoot, int64(column.Index), column); err != nil { + return err + } + } + if a.sentinel != nil { + encoded, err := column.EncodeSSZ(nil) + if err != nil { + return err + } + if err := a.gossipManager.Publish(ctx, gossip.TopicNameDataColumnSidecar(das.ComputeSubnetForDataColumnSidecar(column.Index)), encoded); err != nil { + a.logger.Debug("failed to publish execution payload data column", "err", err) + } + } + } + return nil } // ---- Execution Payload Bid ---- @@ -845,6 +1014,10 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit // POST /eth/v1/beacon/execution_payload_bid // [New in Gloas:EIP7732] func (a *ApiHandler) PostEthV1BeaconExecutionPayloadBid(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/execution_payload_bids") && r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")).WriteTo(w) + return + } req := new(cltypes.SignedExecutionPayloadBid) contentType, err := requestContentType(r) if err != nil { @@ -853,17 +1026,22 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadBid(w http.ResponseWriter, r } switch contentType { case "application/json": - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxEpbsJSONSize)).Decode(req); err != nil { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxEpbsJSONSize)) + if err := decoder.Decode(req); err != nil { beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) return } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("request body contains trailing data")).WriteTo(w) + return + } case "application/octet-stream": octets, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxSignedExecutionPayloadBidSSZSize())) if err != nil { beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) return } - if err := req.DecodeSSZ(octets, int(clparams.GloasVersion)); err != nil { + if err := req.DecodeSSZStrict(octets, int(clparams.GloasVersion)); err != nil { beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) return } @@ -1044,6 +1222,36 @@ func (a *ApiHandler) GetEthV1ValidatorExecutionPayloadEnvelopeBySlot(w http.Resp return newBeaconResponse(envelope).WithVersion(a.beaconChainCfg.GetCurrentStateVersion(epoch)), nil } +func (a *ApiHandler) GetEthV1ValidatorExecutionPayloadEnvelopeByBlockRoot(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { + slotStr, err := beaconhttp.StringFromRequest(r, "slot") + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } + slot, err := strconv.ParseUint(slotStr, 10, 64) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid slot: %w", err)) + } + rootStr, err := beaconhttp.StringFromRequest(r, "beacon_block_root") + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } + var root common.Hash + if err := root.UnmarshalText([]byte(rootStr)); err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid beacon_block_root: %w", err)) + } + if slot != a.ethClock.GetCurrentSlot() { + return nil, beaconhttp.NewEndpointError(http.StatusNotFound, fmt.Errorf("execution payload envelope is only retained for the current slot")) + } + if slot/a.beaconChainCfg.SlotsPerEpoch < a.beaconChainCfg.GloasForkEpoch { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("execution payload envelopes not available before GLOAS fork")) + } + envelope, ok := a.selfBuildEnvelopes.Get(slot) + if !ok || envelope == nil || envelope.BeaconBlockRoot != root { + return nil, beaconhttp.NewEndpointError(http.StatusNotFound, fmt.Errorf("no execution payload envelope found for slot %d and block root %s", slot, root)) + } + return newBeaconResponse(envelope).WithVersion(clparams.GloasVersion), nil +} + // ---- Helpers ---- // blockRootFromBlockId resolves a block_id to a block root hash. diff --git a/cl/beacon/handler/epbs_test.go b/cl/beacon/handler/epbs_test.go index 4d83473f30b..92e475dd5ae 100644 --- a/cl/beacon/handler/epbs_test.go +++ b/cl/beacon/handler/epbs_test.go @@ -25,11 +25,13 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "github.com/erigontech/erigon/cl/beacon/beaconevents" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" @@ -46,6 +48,7 @@ func TestPostPayloadAttestationsRejectsNullMessage(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(`[null]`)) request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request) @@ -165,6 +168,82 @@ func TestPostExecutionPayloadEnvelopeReturnsForkchoiceError(t *testing.T) { require.Contains(t, recorder.Body.String(), "invalid execution payload") } +func TestPostExecutionPayloadEnvelopesRequiresBlobDataHeader(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", strings.NewReader(`{}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), "Eth-Blob-Data-Included") +} + +func TestPostExecutionPayloadEnvelopesRejectsMalformedContents(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", strings.NewReader(`{}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Blob-Data-Included", "true") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) +} + +func TestPostExecutionPayloadEnvelopesRejectsTrailingJSON(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + body, err := json.Marshal(envelope) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", strings.NewReader(string(body)+`{}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Blob-Data-Included", "false") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) +} + +func TestPostExecutionPayloadEnvelopesEmitsImportedAndAvailableEvents(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.emitters = beaconevents.NewEventEmitter() + events := make(chan *beaconevents.EventStream, 2) + subscription := handler.emitters.Operation().Subscribe(events) + defer subscription.Unsubscribe() + + root := common.Hash{1} + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + envelope.Message.BeaconBlockRoot = root + envelope.Message.BuilderIndex = 3 + fcu.Blocks = map[common.Hash]*cltypes.SignedBeaconBlock{ + root: {Block: &cltypes.BeaconBlock{Slot: 12, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}, + } + body, err := json.Marshal(envelope) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", strings.NewReader(string(body))) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Blob-Data-Included", "false") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, beaconevents.OpExecutionPayload, (<-events).Event) + select { + case event := <-events: + require.Equal(t, beaconevents.OpExecutionPayloadAvailable, event.Event) + case <-time.After(time.Second): + t.Fatal("execution_payload_available event was not emitted") + } +} + func TestPostPtcDutiesDoesNotCapValidatorCount(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) handler.beaconChainCfg.GloasForkEpoch = 0 @@ -198,6 +277,22 @@ func TestPostExecutionPayloadBidAcceptsSSZ(t *testing.T) { require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) } +func TestPostExecutionPayloadBidsRejectsTrailingJSON(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(12, 3, 1000)} + body, err := json.Marshal(bid) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bids", strings.NewReader(string(body)+`{}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconExecutionPayloadBid(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), "trailing data") +} + func TestPostExecutionPayloadBidAcceptsQueuedBid(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) ctrl := gomock.NewController(t) @@ -542,6 +637,36 @@ func TestGetValidatorExecutionPayloadEnvelopesBySlot(t *testing.T) { require.Contains(t, recorder.Body.String(), `"builder_index":"7"`) } +func TestGetValidatorExecutionPayloadEnvelopeByBlockRoot(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 0 + slot := handler.ethClock.GetCurrentSlot() + root := common.HexToHash("0x1234") + envelope := cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg) + envelope.BeaconBlockRoot = root + handler.selfBuildEnvelopes.Add(slot, envelope) + + tests := []struct { + name string + slot uint64 + root common.Hash + want int + }{ + {name: "matching current slot and root", slot: slot, root: root, want: http.StatusOK}, + {name: "wrong root", slot: slot, root: common.HexToHash("0x5678"), want: http.StatusNotFound}, + {name: "old slot", slot: slot - 1, root: root, want: http.StatusNotFound}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, + fmt.Sprintf("/eth/v1/validator/execution_payload_envelopes/%d/%s", tt.slot, tt.root), http.NoBody) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, tt.want, recorder.Code, recorder.Body.String()) + }) + } +} + func newTestExecutionPayloadBid(slot, builderIndex, value uint64) *cltypes.ExecutionPayloadBid { return &cltypes.ExecutionPayloadBid{ ParentBlockHash: common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111"), diff --git a/cl/beacon/handler/events.go b/cl/beacon/handler/events.go index 91a955b548e..d3a37abdf6d 100644 --- a/cl/beacon/handler/events.go +++ b/cl/beacon/handler/events.go @@ -44,6 +44,9 @@ var validTopics = map[event.EventTopic]struct{}{ event.OpPayloadAttestationMessage: {}, event.OpExecutionPayloadBid: {}, event.OpExecutionPayloadAvailable: {}, + event.OpExecutionPayload: {}, + event.OpExecutionPayloadGossip: {}, + event.OpProposerPreferences: {}, // state events event.StateBlock: {}, event.StateBlockGossip: {}, @@ -51,8 +54,10 @@ var validTopics = map[event.EventTopic]struct{}{ event.StateLightClientFinalityUpdate: {}, event.StateFinalizedCheckpoint: {}, event.StateHead: {}, + event.StateHeadV2: {}, event.StateLightClientOptimisticUpdate: {}, event.StatePayloadAttributes: {}, + event.StateFastConfirmation: {}, } func (a *ApiHandler) EventSourceGetV1Events(w http.ResponseWriter, r *http.Request) { diff --git a/cl/beacon/handler/events_gloas_test.go b/cl/beacon/handler/events_gloas_test.go new file mode 100644 index 00000000000..0e685ff65b6 --- /dev/null +++ b/cl/beacon/handler/events_gloas_test.go @@ -0,0 +1,40 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package handler + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cl/beacon/beaconevents" +) + +func TestGloasEventTopicsAreValid(t *testing.T) { + for _, topic := range []beaconevents.EventTopic{ + beaconevents.StateHeadV2, + beaconevents.OpExecutionPayload, + beaconevents.OpExecutionPayloadGossip, + beaconevents.OpExecutionPayloadAvailable, + beaconevents.OpExecutionPayloadBid, + beaconevents.OpPayloadAttestationMessage, + beaconevents.OpProposerPreferences, + } { + _, ok := validTopics[topic] + require.True(t, ok, topic) + } +} diff --git a/cl/beacon/handler/handler.go b/cl/beacon/handler/handler.go index 0fdda5db2e4..051c61cb8ed 100644 --- a/cl/beacon/handler/handler.go +++ b/cl/beacon/handler/handler.go @@ -153,6 +153,7 @@ type ApiHandler struct { // Populated during block production alongside selfBuildPayloads. // [New in Gloas:EIP7732] selfBuildEnvelopes *lru.Cache[uint64, *cltypes.ExecutionPayloadEnvelope] + builderRoutes *lru.Cache[common.Hash, string] } func NewApiHandler( @@ -221,6 +222,10 @@ func NewApiHandler( if err != nil { panic(err) } + builderRoutes, err := lru.New[common.Hash, string]("builderRoutes", 16) + if err != nil { + panic(err) + } return &ApiHandler{ logger: logger, validatorParams: validatorParams, @@ -270,6 +275,7 @@ func NewApiHandler( proposerPreferencesService: proposerPreferencesService, selfBuildPayloads: selfBuildPayloads, selfBuildEnvelopes: selfBuildEnvelopes, + builderRoutes: builderRoutes, } } @@ -376,6 +382,7 @@ func (a *ApiHandler) init() { r.Post("/execution_payload_envelope", a.PostEthV1BeaconExecutionPayloadEnvelope) r.Post("/execution_payload_envelopes", a.PostEthV1BeaconExecutionPayloadEnvelope) r.Post("/execution_payload_bid", a.PostEthV1BeaconExecutionPayloadBid) + r.Post("/execution_payload_bids", a.PostEthV1BeaconExecutionPayloadBid) r.Route("/states", func(r chi.Router) { r.Route("/{state_id}", func(r chi.Router) { r.Get("/randao", beaconhttp.HandleEndpointFunc(a.getRandao)) @@ -386,6 +393,7 @@ func (a *ApiHandler) init() { r.Get("/fork", beaconhttp.HandleEndpointFunc(a.getStateFork)) r.Get("/validators", a.GetEthV1BeaconStatesValidators) r.Post("/validators", a.PostEthV1BeaconStatesValidators) + r.Post("/builders", beaconhttp.HandleEndpointFunc(a.PostEthV1BeaconStatesBuilders)) r.Get("/validator_balances", beaconhttp.HandleEndpointFunc(a.GetEthV1BeaconValidatorsBalances)) r.Post("/validator_balances", beaconhttp.HandleEndpointFunc(a.PostEthV1BeaconValidatorsBalances)) r.Get("/validators/{validator_id}", beaconhttp.HandleEndpointFunc(a.GetEthV1BeaconStatesValidator)) @@ -420,8 +428,11 @@ func (a *ApiHandler) init() { // [New in Gloas:EIP7732] r.Get("/payload_attestation_data/{slot}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorPayloadAttestationData)) r.Post("/proposer_preferences", a.PostEthV1ValidatorProposerPreferences) + r.Post("/builder_preferences", a.PostEthV1ValidatorBuilderPreferences) r.Get("/execution_payload_bid/{slot}/{builder_index}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorExecutionPayloadBid)) + r.Get("/execution_payload_bids/{slot}/{builder_index}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorExecutionPayloadBid)) r.Get("/execution_payload_envelope/{slot}/{builder_index}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorExecutionPayloadEnvelope)) + r.Get("/execution_payload_envelopes/{slot}/{beacon_block_root}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorExecutionPayloadEnvelopeByBlockRoot)) r.Get("/execution_payload_envelopes/{slot}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorExecutionPayloadEnvelopeBySlot)) if a.routerCfg.Builder { r.Post("/register_validator", beaconhttp.HandleEndpointFunc(a.PostEthV1BuilderRegisterValidator)) @@ -470,6 +481,8 @@ func (a *ApiHandler) init() { if a.routerCfg.Validator { r.Get("/v3/validator/blocks/{slot}", beaconhttp.HandleEndpointFunc(a.GetEthV3ValidatorBlock)) r.Get("/v4/validator/blocks/{slot}", beaconhttp.HandleEndpointFunc(a.GetEthV3ValidatorBlock)) + r.Post("/v4/validator/blocks/{slot}", beaconhttp.HandleEndpointFunc(a.PostEthV4ValidatorBlock)) + r.Post("/v4/validator/blocks/{slot}/with_bid", beaconhttp.HandleEndpointFunc(a.PostEthV4ValidatorBlockWithBid)) } }) } diff --git a/cl/beacon/handler/state_builders_test.go b/cl/beacon/handler/state_builders_test.go new file mode 100644 index 00000000000..f59329934f2 --- /dev/null +++ b/cl/beacon/handler/state_builders_test.go @@ -0,0 +1,152 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package handler + +import ( + "bytes" + "encoding/json" + "math" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cl/clparams" + "github.com/erigontech/erigon/cl/cltypes" + "github.com/erigontech/erigon/cl/cltypes/solid" + "github.com/erigontech/erigon/cl/phase1/core/state" + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/log/v3" +) + +type stateBuildersHTTPResponse struct { + ExecutionOptimistic bool `json:"execution_optimistic"` + Finalized bool `json:"finalized"` + Data []struct { + Index string `json:"index"` + Status string `json:"status"` + Builder struct { + Pubkey common.Bytes48 `json:"pubkey"` + Version string `json:"version"` + } `json:"builder"` + } `json:"data"` +} + +func setupStateBuildersHandler(t *testing.T) (*ApiHandler, *state.CachingBeaconState, [3]*cltypes.Builder) { + t.Helper() + _, blocks, _, _, postState, handler, _, syncedData, fcu, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + + postState.SetVersion(clparams.GloasVersion) + postState.SetFinalizedCheckpoint(solid.Checkpoint{Epoch: 5}) + builders := [3]*cltypes.Builder{ + {Pubkey: common.Bytes48{1}, Balance: 11, DepositEpoch: 5, WithdrawableEpoch: math.MaxUint64}, + {Pubkey: common.Bytes48{2}, Balance: 22, DepositEpoch: 4, WithdrawableEpoch: math.MaxUint64}, + {Pubkey: common.Bytes48{3}, Balance: 33, DepositEpoch: 1, WithdrawableEpoch: 7}, + } + registry := solid.NewStaticListSSZ[*cltypes.Builder](int(postState.BeaconConfig().BuilderRegistryLimit), new(cltypes.Builder).EncodingSizeSSZ()) + for _, builder := range builders { + registry.Append(builder) + } + postState.SetBuilders(registry) + syncedData.OnHeadState(postState) + fcu.HeadVal, _ = blocks[len(blocks)-1].Block.HashSSZ() + fcu.HeadSlotVal = postState.Slot() + fcu.IsRootOptimisticVal = true + return handler, postState, builders +} + +func postStateBuilders(t *testing.T, handler *ApiHandler, stateID, body string) (*http.Response, stateBuildersHTTPResponse) { + t.Helper() + server := httptest.NewServer(handler.mux) + t.Cleanup(server.Close) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL+"/eth/v1/beacon/states/"+stateID+"/builders", bytes.NewBufferString(body)) + require.NoError(t, err) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + resp, err := server.Client().Do(req) + require.NoError(t, err) + var decoded stateBuildersHTTPResponse + if resp.StatusCode == http.StatusOK { + require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded)) + } + return resp, decoded +} + +func TestPostStateBuildersReturnsAndFiltersBuilders(t *testing.T) { + handler, _, builders := setupStateBuildersHandler(t) + + t.Run("all without body", func(t *testing.T) { + resp, decoded := postStateBuilders(t, handler, "head", "") + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.True(t, decoded.ExecutionOptimistic) + require.False(t, decoded.Finalized) + require.Equal(t, []string{"0", "1", "2"}, []string{decoded.Data[0].Index, decoded.Data[1].Index, decoded.Data[2].Index}) + require.Equal(t, []string{"pending", "active", "exited"}, []string{decoded.Data[0].Status, decoded.Data[1].Status, decoded.Data[2].Status}) + require.Equal(t, "0", decoded.Data[0].Builder.Version) + require.Equal(t, builders[0].Pubkey, decoded.Data[0].Builder.Pubkey) + }) + + t.Run("index pubkey and status", func(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "ids": []string{"0", builders[1].Pubkey.String(), "999"}, + "statuses": []string{"active", "pending"}, + }) + require.NoError(t, err) + resp, decoded := postStateBuilders(t, handler, "head", string(body)) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Len(t, decoded.Data, 2) + require.Equal(t, []string{"0", "1"}, []string{decoded.Data[0].Index, decoded.Data[1].Index}) + }) +} + +func TestPostStateBuildersRejectsInvalidFilters(t *testing.T) { + handler, _, _ := setupStateBuildersHandler(t) + tests := []string{ + `{"ids":["nope"]}`, + `{"ids":["0","0"]}`, + `{"statuses":["unknown"]}`, + `{"statuses":["active","active"]}`, + `{"ids":`, + `{"unknown":true}`, + `null`, + `{} {}`, + `{"ids":["01","1"]}`, + } + for _, body := range tests { + t.Run(body, func(t *testing.T) { + resp, _ := postStateBuilders(t, handler, "head", body) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + } +} + +func TestPostStateBuildersRejectsPreGloasAndMissingState(t *testing.T) { + _, _, _, _, _, preGloasHandler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + resp, _ := postStateBuilders(t, preGloasHandler, "head", `{}`) + resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + + handler, _, _ := setupStateBuildersHandler(t) + resp, _ = postStateBuilders(t, handler, "finalized", `{}`) + defer resp.Body.Close() + require.Equal(t, http.StatusNotFound, resp.StatusCode) +} diff --git a/cl/cltypes/epbs_builder_api.go b/cl/cltypes/epbs_builder_api.go new file mode 100644 index 00000000000..7175c344c9b --- /dev/null +++ b/cl/cltypes/epbs_builder_api.go @@ -0,0 +1,804 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package cltypes + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "unicode/utf8" + + "github.com/erigontech/erigon/cl/cltypes/solid" + "github.com/erigontech/erigon/cl/merkle_tree" + ssz2 "github.com/erigontech/erigon/cl/ssz" + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/clonable" + "github.com/erigontech/erigon/common/hexutil" + commonssz "github.com/erigontech/erigon/common/ssz" +) + +const ( + MaxBuilderAuthDataSize = 4096 + MaxBuilderEntries = 64 + MaxBuilderURLSize = 2048 + MaxBuilderPubkeys = 64 + MaxBuilderPreferencesEntries = 4096 +) + +type BuilderRequestAuth struct { + Data hexutil.Bytes `json:"data"` + Slot uint64 `json:"slot,string"` +} + +func (b *BuilderRequestAuth) Static() bool { return false } + +func (b *BuilderRequestAuth) EncodingSizeSSZ() int { return 4 + 8 + len(b.Data) } + +func (b *BuilderRequestAuth) EncodeSSZ(dst []byte) ([]byte, error) { + if err := validateBuilderAuthData(b.Data); err != nil { + return nil, err + } + return ssz2.MarshalSSZ(dst, &rawByteList{value: &b.Data, limit: MaxBuilderAuthDataSize}, b.Slot) +} + +func (b *BuilderRequestAuth) DecodeSSZ(buf []byte, version int) error { + return b.DecodeSSZStrict(buf, version) +} + +func (b *BuilderRequestAuth) DecodeSSZStrict(buf []byte, version int) error { + if err := ssz2.UnmarshalSSZStrict(buf, version, &rawByteList{value: &b.Data, limit: MaxBuilderAuthDataSize}, &b.Slot); err != nil { + return err + } + return validateBuilderAuthData(b.Data) +} + +func (b *BuilderRequestAuth) Clone() clonable.Clonable { + if b == nil { + return &BuilderRequestAuth{} + } + return &BuilderRequestAuth{Data: bytes.Clone(b.Data), Slot: b.Slot} +} + +func (b *BuilderRequestAuth) HashSSZ() ([32]byte, error) { + if err := validateBuilderAuthData(b.Data); err != nil { + return [32]byte{}, err + } + data := solid.NewByteListSSZ(MaxBuilderAuthDataSize) + if err := data.SetBytes(b.Data); err != nil { + return [32]byte{}, err + } + return merkle_tree.HashTreeRoot(data, b.Slot) +} + +func (b *BuilderRequestAuth) UnmarshalJSON(data []byte) error { + var value struct { + Data *hexutil.Bytes `json:"data"` + Slot *uint64 `json:"slot,string"` + } + if err := decodeStrictJSON(data, &value); err != nil { + return err + } + if value.Data == nil || value.Slot == nil { + return errors.New("builder request auth requires data and slot") + } + if err := validateBuilderAuthData(*value.Data); err != nil { + return err + } + b.Data = bytes.Clone(*value.Data) + b.Slot = *value.Slot + return nil +} + +func (b BuilderRequestAuth) MarshalJSON() ([]byte, error) { + if err := validateBuilderAuthData(b.Data); err != nil { + return nil, err + } + type builderRequestAuth BuilderRequestAuth + return json.Marshal(builderRequestAuth(b)) +} + +type SignedBuilderRequestAuth struct { + Message *BuilderRequestAuth `json:"message"` + Signature common.Bytes96 `json:"signature"` +} + +func (s *SignedBuilderRequestAuth) Static() bool { return false } + +func (s *SignedBuilderRequestAuth) EncodingSizeSSZ() int { + if s.Message == nil { + return 4 + len(s.Signature) + } + return 4 + len(s.Signature) + s.Message.EncodingSizeSSZ() +} + +func (s *SignedBuilderRequestAuth) EncodeSSZ(dst []byte) ([]byte, error) { + if s.Message == nil { + return nil, errors.New("signed builder request auth has nil message") + } + return ssz2.MarshalSSZ(dst, s.Message, s.Signature[:]) +} + +func (s *SignedBuilderRequestAuth) DecodeSSZ(buf []byte, version int) error { + return s.DecodeSSZStrict(buf, version) +} + +func (s *SignedBuilderRequestAuth) DecodeSSZStrict(buf []byte, version int) error { + s.Message = new(BuilderRequestAuth) + return ssz2.UnmarshalSSZStrict(buf, version, s.Message, s.Signature[:]) +} + +func (s *SignedBuilderRequestAuth) Clone() clonable.Clonable { + if s == nil { + return &SignedBuilderRequestAuth{} + } + var message *BuilderRequestAuth + if s.Message != nil { + message = s.Message.Clone().(*BuilderRequestAuth) + } + return &SignedBuilderRequestAuth{Message: message, Signature: s.Signature} +} + +func (s *SignedBuilderRequestAuth) HashSSZ() ([32]byte, error) { + if s.Message == nil { + return [32]byte{}, errors.New("signed builder request auth has nil message") + } + return merkle_tree.HashTreeRoot(s.Message, s.Signature[:]) +} + +func (s *SignedBuilderRequestAuth) UnmarshalJSON(data []byte) error { + var value struct { + Message *BuilderRequestAuth `json:"message"` + Signature *common.Bytes96 `json:"signature"` + } + if err := decodeStrictJSON(data, &value); err != nil { + return err + } + if value.Message == nil || value.Signature == nil { + return errors.New("signed builder request auth requires message and signature") + } + s.Message = value.Message + s.Signature = *value.Signature + return nil +} + +func (s SignedBuilderRequestAuth) MarshalJSON() ([]byte, error) { + if s.Message == nil { + return nil, errors.New("signed builder request auth has nil message") + } + if err := validateBuilderAuthData(s.Message.Data); err != nil { + return nil, err + } + type signedBuilderRequestAuth SignedBuilderRequestAuth + return json.Marshal(signedBuilderRequestAuth(s)) +} + +type BuilderEntry struct { + URL string `json:"url"` + Auth *SignedBuilderRequestAuth `json:"auth"` + BuilderPubkeys []common.Bytes48 `json:"builder_pubkeys"` + MaxExecutionPayment uint64 `json:"max_execution_payment,string"` + MinBid uint64 `json:"min_bid,string"` + BuilderBoostFactor uint64 `json:"builder_boost_factor,string"` +} + +func (b *BuilderEntry) Static() bool { return false } + +func (b *BuilderEntry) EncodingSizeSSZ() int { + size := 4 + 4 + 4 + 8 + 8 + 8 + len(b.URL) + len(b.BuilderPubkeys)*len(common.Bytes48{}) + if b.Auth != nil { + size += b.Auth.EncodingSizeSSZ() + } + return size +} + +func (b *BuilderEntry) EncodeSSZ(dst []byte) ([]byte, error) { + if err := b.validate(); err != nil { + return nil, err + } + urlBytes := hexutil.Bytes(b.URL) + return ssz2.MarshalSSZ(dst, + &rawByteList{value: &urlBytes, limit: MaxBuilderURLSize}, + b.Auth, + &builderPubkeyList{values: &b.BuilderPubkeys}, + b.MaxExecutionPayment, + b.MinBid, + b.BuilderBoostFactor, + ) +} + +func (b *BuilderEntry) DecodeSSZ(buf []byte, version int) error { + return b.DecodeSSZStrict(buf, version) +} + +func (b *BuilderEntry) DecodeSSZStrict(buf []byte, version int) error { + var urlBytes hexutil.Bytes + b.Auth = new(SignedBuilderRequestAuth) + b.BuilderPubkeys = nil + if err := ssz2.UnmarshalSSZStrict(buf, version, + &rawByteList{value: &urlBytes, limit: MaxBuilderURLSize}, + b.Auth, + &builderPubkeyList{values: &b.BuilderPubkeys}, + &b.MaxExecutionPayment, + &b.MinBid, + &b.BuilderBoostFactor, + ); err != nil { + return err + } + b.URL = string(urlBytes) + return b.validate() +} + +func (b *BuilderEntry) Clone() clonable.Clonable { + if b == nil { + return &BuilderEntry{} + } + var auth *SignedBuilderRequestAuth + if b.Auth != nil { + auth = b.Auth.Clone().(*SignedBuilderRequestAuth) + } + return &BuilderEntry{ + URL: b.URL, Auth: auth, BuilderPubkeys: append([]common.Bytes48(nil), b.BuilderPubkeys...), + MaxExecutionPayment: b.MaxExecutionPayment, MinBid: b.MinBid, BuilderBoostFactor: b.BuilderBoostFactor, + } +} + +func (b *BuilderEntry) UnmarshalJSON(data []byte) error { + var value struct { + URL *string `json:"url"` + Auth *SignedBuilderRequestAuth `json:"auth"` + BuilderPubkeys *[]common.Bytes48 `json:"builder_pubkeys"` + MaxExecutionPayment *uint64 `json:"max_execution_payment,string"` + MinBid *uint64 `json:"min_bid,string"` + BuilderBoostFactor *uint64 `json:"builder_boost_factor,string"` + } + if err := decodeStrictJSON(data, &value); err != nil { + return err + } + if value.URL == nil || value.Auth == nil || value.BuilderPubkeys == nil || value.MaxExecutionPayment == nil || value.MinBid == nil || value.BuilderBoostFactor == nil { + return errors.New("builder entry is missing a required field") + } + *b = BuilderEntry{URL: *value.URL, Auth: value.Auth, BuilderPubkeys: *value.BuilderPubkeys, MaxExecutionPayment: *value.MaxExecutionPayment, MinBid: *value.MinBid, BuilderBoostFactor: *value.BuilderBoostFactor} + return b.validate() +} + +func (b BuilderEntry) MarshalJSON() ([]byte, error) { + if err := b.validate(); err != nil { + return nil, err + } + type builderEntry BuilderEntry + return json.Marshal(builderEntry(b)) +} + +func (b *BuilderEntry) validate() error { + if err := validateBuilderURL(b.URL); err != nil { + return err + } + if b.Auth == nil || b.Auth.Message == nil { + return errors.New("builder entry has nil auth") + } + if len(b.BuilderPubkeys) > MaxBuilderPubkeys { + return fmt.Errorf("builder pubkey count %d exceeds %d", len(b.BuilderPubkeys), MaxBuilderPubkeys) + } + return validateBuilderAuthData(b.Auth.Message.Data) +} + +type BuilderConfig struct { + MinBid uint64 `json:"min_bid,string"` + BuilderBoostFactor uint64 `json:"builder_boost_factor,string"` + Builders []*BuilderEntry `json:"builders"` +} + +func (b *BuilderConfig) Static() bool { return false } + +func (b *BuilderConfig) EncodingSizeSSZ() int { + return 8 + 8 + 4 + (&builderEntryList{values: &b.Builders}).EncodingSizeSSZ() +} + +func (b *BuilderConfig) EncodeSSZ(dst []byte) ([]byte, error) { + if err := b.validate(); err != nil { + return nil, err + } + return ssz2.MarshalSSZ(dst, b.MinBid, b.BuilderBoostFactor, &builderEntryList{values: &b.Builders}) +} + +func (b *BuilderConfig) DecodeSSZ(buf []byte, version int) error { + return b.DecodeSSZStrict(buf, version) +} + +func (b *BuilderConfig) DecodeSSZStrict(buf []byte, version int) error { + b.Builders = nil + if err := ssz2.UnmarshalSSZStrict(buf, version, &b.MinBid, &b.BuilderBoostFactor, &builderEntryList{values: &b.Builders}); err != nil { + return err + } + return b.validate() +} + +func (b *BuilderConfig) Clone() clonable.Clonable { + clone := &BuilderConfig{MinBid: b.MinBid, BuilderBoostFactor: b.BuilderBoostFactor, Builders: make([]*BuilderEntry, len(b.Builders))} + for i, entry := range b.Builders { + if entry != nil { + clone.Builders[i] = entry.Clone().(*BuilderEntry) + } + } + return clone +} + +func (b *BuilderConfig) UnmarshalJSON(data []byte) error { + var value struct { + MinBid *uint64 `json:"min_bid,string"` + BuilderBoostFactor *uint64 `json:"builder_boost_factor,string"` + Builders *[]*BuilderEntry `json:"builders"` + } + if err := decodeStrictJSON(data, &value); err != nil { + return err + } + if value.MinBid == nil || value.BuilderBoostFactor == nil || value.Builders == nil { + return errors.New("builder config is missing a required field") + } + if len(*value.Builders) > MaxBuilderEntries { + return fmt.Errorf("builder count %d exceeds %d", len(*value.Builders), MaxBuilderEntries) + } + for i, entry := range *value.Builders { + if entry == nil { + return fmt.Errorf("builder %d is nil", i) + } + } + *b = BuilderConfig{MinBid: *value.MinBid, BuilderBoostFactor: *value.BuilderBoostFactor, Builders: *value.Builders} + return b.validate() +} + +func (b BuilderConfig) MarshalJSON() ([]byte, error) { + if b.Builders == nil { + return nil, errors.New("builder config has nil builders") + } + if err := b.validate(); err != nil { + return nil, err + } + type builderConfig BuilderConfig + return json.Marshal(builderConfig(b)) +} + +func (b *BuilderConfig) validate() error { + if len(b.Builders) > MaxBuilderEntries { + return fmt.Errorf("builder count %d exceeds %d", len(b.Builders), MaxBuilderEntries) + } + seen := make(map[string]struct{}, len(b.Builders)) + for i, entry := range b.Builders { + if entry == nil { + return fmt.Errorf("builder %d is nil", i) + } + if err := entry.validate(); err != nil { + return fmt.Errorf("builder %d: %w", i, err) + } + key := entry.URL + "\x00" + string(entry.Auth.Message.Data) + if _, ok := seen[key]; ok { + return fmt.Errorf("builder %d duplicates URL and auth data", i) + } + seen[key] = struct{}{} + } + return nil +} + +type BuilderPreferencesEntry struct { + ProposerPubkey common.Bytes48 `json:"proposer_pubkey"` + URL string `json:"url"` + Auth *SignedBuilderRequestAuth `json:"auth"` + MaxExecutionPayment uint64 `json:"max_execution_payment,string"` +} + +type BuilderPreferencesEntries []*BuilderPreferencesEntry + +func (b BuilderPreferencesEntries) Static() bool { return false } + +func (b BuilderPreferencesEntries) EncodingSizeSSZ() int { + size := len(b) * 4 + for _, entry := range b { + if entry != nil { + size += entry.EncodingSizeSSZ() + } + } + return size +} + +func (b BuilderPreferencesEntries) EncodeSSZ(dst []byte) ([]byte, error) { + if err := b.validate(); err != nil { + return nil, err + } + return commonssz.EncodeDynamicList(dst, b) +} + +func (b *BuilderPreferencesEntries) DecodeSSZ(buf []byte, version int) error { + return b.DecodeSSZStrict(buf, version) +} + +func (b *BuilderPreferencesEntries) DecodeSSZStrict(buf []byte, version int) error { + entries, err := commonssz.DecodeDynamicListStrict[*BuilderPreferencesEntry](buf, 0, uint32(len(buf)), MaxBuilderPreferencesEntries, version) + if err != nil { + return err + } + *b = entries + return b.validate() +} + +func (b BuilderPreferencesEntries) Clone() clonable.Clonable { + clone := make(BuilderPreferencesEntries, len(b)) + for i, entry := range b { + if entry != nil { + clone[i] = entry.Clone().(*BuilderPreferencesEntry) + } + } + return &clone +} + +func (b BuilderPreferencesEntries) MarshalJSON() ([]byte, error) { + if err := b.validate(); err != nil { + return nil, err + } + if b == nil { + return []byte("[]"), nil + } + type builderPreferencesEntries BuilderPreferencesEntries + return json.Marshal(builderPreferencesEntries(b)) +} + +func (b *BuilderPreferencesEntries) UnmarshalJSON(data []byte) error { + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + return errors.New("builder preferences entries cannot be null") + } + type builderPreferencesEntries BuilderPreferencesEntries + var entries builderPreferencesEntries + if err := json.Unmarshal(data, &entries); err != nil { + return err + } + *b = BuilderPreferencesEntries(entries) + return b.validate() +} + +func (b BuilderPreferencesEntries) validate() error { + if len(b) > MaxBuilderPreferencesEntries { + return fmt.Errorf("builder preferences entry count %d exceeds %d", len(b), MaxBuilderPreferencesEntries) + } + for i, entry := range b { + if entry == nil { + return fmt.Errorf("builder preferences entry %d is nil", i) + } + if err := entry.validate(); err != nil { + return fmt.Errorf("builder preferences entry %d: %w", i, err) + } + } + return nil +} + +func (b *BuilderPreferencesEntry) Static() bool { return false } + +func (b *BuilderPreferencesEntry) EncodingSizeSSZ() int { + size := len(b.ProposerPubkey) + 4 + 4 + 8 + len(b.URL) + if b.Auth != nil { + size += b.Auth.EncodingSizeSSZ() + } + return size +} + +func (b *BuilderPreferencesEntry) EncodeSSZ(dst []byte) ([]byte, error) { + if err := b.validate(); err != nil { + return nil, err + } + urlBytes := hexutil.Bytes(b.URL) + return ssz2.MarshalSSZ(dst, b.ProposerPubkey[:], &rawByteList{value: &urlBytes, limit: MaxBuilderURLSize}, b.Auth, b.MaxExecutionPayment) +} + +func (b *BuilderPreferencesEntry) DecodeSSZ(buf []byte, version int) error { + return b.DecodeSSZStrict(buf, version) +} + +func (b *BuilderPreferencesEntry) DecodeSSZStrict(buf []byte, version int) error { + var urlBytes hexutil.Bytes + b.Auth = new(SignedBuilderRequestAuth) + if err := ssz2.UnmarshalSSZStrict(buf, version, b.ProposerPubkey[:], &rawByteList{value: &urlBytes, limit: MaxBuilderURLSize}, b.Auth, &b.MaxExecutionPayment); err != nil { + return err + } + b.URL = string(urlBytes) + return b.validate() +} + +func (b *BuilderPreferencesEntry) Clone() clonable.Clonable { + if b == nil { + return &BuilderPreferencesEntry{} + } + var auth *SignedBuilderRequestAuth + if b.Auth != nil { + auth = b.Auth.Clone().(*SignedBuilderRequestAuth) + } + return &BuilderPreferencesEntry{ProposerPubkey: b.ProposerPubkey, URL: b.URL, Auth: auth, MaxExecutionPayment: b.MaxExecutionPayment} +} + +func (b *BuilderPreferencesEntry) UnmarshalJSON(data []byte) error { + var value struct { + ProposerPubkey *common.Bytes48 `json:"proposer_pubkey"` + URL *string `json:"url"` + Auth *SignedBuilderRequestAuth `json:"auth"` + MaxExecutionPayment *uint64 `json:"max_execution_payment,string"` + } + if err := decodeStrictJSON(data, &value); err != nil { + return err + } + if value.ProposerPubkey == nil || value.URL == nil || value.Auth == nil || value.MaxExecutionPayment == nil { + return errors.New("builder preferences entry is missing a required field") + } + *b = BuilderPreferencesEntry{ProposerPubkey: *value.ProposerPubkey, URL: *value.URL, Auth: value.Auth, MaxExecutionPayment: *value.MaxExecutionPayment} + return b.validate() +} + +func (b BuilderPreferencesEntry) MarshalJSON() ([]byte, error) { + if err := b.validate(); err != nil { + return nil, err + } + type builderPreferencesEntry BuilderPreferencesEntry + return json.Marshal(builderPreferencesEntry(b)) +} + +func (b *BuilderPreferencesEntry) validate() error { + if err := validateBuilderURL(b.URL); err != nil { + return err + } + if b.Auth == nil || b.Auth.Message == nil { + return errors.New("builder preferences entry has nil auth") + } + return validateBuilderAuthData(b.Auth.Message.Data) +} + +type BuilderPreferences struct { + MaxExecutionPayment uint64 `json:"max_execution_payment,string"` +} + +func (b *BuilderPreferences) Static() bool { return true } +func (b *BuilderPreferences) EncodingSizeSSZ() int { return 8 } +func (b *BuilderPreferences) EncodeSSZ(dst []byte) ([]byte, error) { + return ssz2.MarshalSSZ(dst, b.MaxExecutionPayment) +} +func (b *BuilderPreferences) DecodeSSZ(buf []byte, version int) error { + return ssz2.UnmarshalSSZStrict(buf, version, &b.MaxExecutionPayment) +} +func (b *BuilderPreferences) DecodeSSZStrict(buf []byte, version int) error { + return b.DecodeSSZ(buf, version) +} +func (b *BuilderPreferences) Clone() clonable.Clonable { + return &BuilderPreferences{MaxExecutionPayment: b.MaxExecutionPayment} +} + +func (b *BuilderPreferences) HashSSZ() ([32]byte, error) { + return merkle_tree.HashTreeRoot(b.MaxExecutionPayment) +} + +func (b *BuilderPreferences) UnmarshalJSON(data []byte) error { + var value struct { + MaxExecutionPayment *uint64 `json:"max_execution_payment,string"` + } + if err := decodeStrictJSON(data, &value); err != nil { + return err + } + if value.MaxExecutionPayment == nil { + return errors.New("builder preferences requires max_execution_payment") + } + b.MaxExecutionPayment = *value.MaxExecutionPayment + return nil +} + +type BuilderPreferencesRequest struct { + Preferences *BuilderPreferences `json:"preferences"` + Auth *SignedBuilderRequestAuth `json:"auth"` +} + +func (b *BuilderPreferencesRequest) Static() bool { return false } +func (b *BuilderPreferencesRequest) EncodingSizeSSZ() int { + size := 8 + 4 + if b.Auth != nil { + size += b.Auth.EncodingSizeSSZ() + } + return size +} +func (b *BuilderPreferencesRequest) EncodeSSZ(dst []byte) ([]byte, error) { + if b.Preferences == nil || b.Auth == nil || b.Auth.Message == nil { + return nil, errors.New("builder preferences request has nil field") + } + return ssz2.MarshalSSZ(dst, b.Preferences, b.Auth) +} +func (b *BuilderPreferencesRequest) DecodeSSZ(buf []byte, version int) error { + return b.DecodeSSZStrict(buf, version) +} +func (b *BuilderPreferencesRequest) DecodeSSZStrict(buf []byte, version int) error { + b.Preferences = new(BuilderPreferences) + b.Auth = new(SignedBuilderRequestAuth) + return ssz2.UnmarshalSSZStrict(buf, version, b.Preferences, b.Auth) +} +func (b *BuilderPreferencesRequest) Clone() clonable.Clonable { + clone := &BuilderPreferencesRequest{} + if b.Preferences != nil { + clone.Preferences = b.Preferences.Clone().(*BuilderPreferences) + } + if b.Auth != nil { + clone.Auth = b.Auth.Clone().(*SignedBuilderRequestAuth) + } + return clone +} + +func (b *BuilderPreferencesRequest) HashSSZ() ([32]byte, error) { + if b.Preferences == nil || b.Auth == nil { + return [32]byte{}, errors.New("builder preferences request has nil field") + } + return merkle_tree.HashTreeRoot(b.Preferences, b.Auth) +} +func (b *BuilderPreferencesRequest) UnmarshalJSON(data []byte) error { + var value struct { + Preferences *BuilderPreferences `json:"preferences"` + Auth *SignedBuilderRequestAuth `json:"auth"` + } + if err := decodeStrictJSON(data, &value); err != nil { + return err + } + if value.Preferences == nil || value.Auth == nil { + return errors.New("builder preferences request is missing a required field") + } + b.Preferences, b.Auth = value.Preferences, value.Auth + return nil +} + +func (b BuilderPreferencesRequest) MarshalJSON() ([]byte, error) { + if b.Preferences == nil || b.Auth == nil || b.Auth.Message == nil { + return nil, errors.New("builder preferences request has nil field") + } + type builderPreferencesRequest BuilderPreferencesRequest + return json.Marshal(builderPreferencesRequest(b)) +} + +type rawByteList struct { + value *hexutil.Bytes + limit int +} + +func (r *rawByteList) Static() bool { return false } +func (r *rawByteList) EncodingSizeSSZ() int { return len(*r.value) } +func (r *rawByteList) EncodeSSZ(dst []byte) ([]byte, error) { + if len(*r.value) > r.limit { + return nil, fmt.Errorf("byte list length %d exceeds %d", len(*r.value), r.limit) + } + return append(dst, (*r.value)...), nil +} +func (r *rawByteList) DecodeSSZ(buf []byte, _ int) error { + if len(buf) > r.limit { + return fmt.Errorf("byte list length %d exceeds %d", len(buf), r.limit) + } + *r.value = bytes.Clone(buf) + return nil +} +func (r *rawByteList) DecodeSSZStrict(buf []byte, version int) error { + return r.DecodeSSZ(buf, version) +} +func (r *rawByteList) Clone() clonable.Clonable { + value := hexutil.Bytes(nil) + return &rawByteList{value: &value, limit: r.limit} +} + +type builderPubkeyList struct{ values *[]common.Bytes48 } + +func (l *builderPubkeyList) Static() bool { return false } +func (l *builderPubkeyList) EncodingSizeSSZ() int { return len(*l.values) * len(common.Bytes48{}) } +func (l *builderPubkeyList) EncodeSSZ(dst []byte) ([]byte, error) { + if len(*l.values) > MaxBuilderPubkeys { + return nil, fmt.Errorf("builder pubkey count %d exceeds %d", len(*l.values), MaxBuilderPubkeys) + } + for i := range *l.values { + dst = append(dst, (*l.values)[i][:]...) + } + return dst, nil +} +func (l *builderPubkeyList) DecodeSSZ(buf []byte, _ int) error { + if len(buf)%len(common.Bytes48{}) != 0 { + return commonssz.ErrLowBufferSize + } + count := len(buf) / len(common.Bytes48{}) + if count > MaxBuilderPubkeys { + return fmt.Errorf("builder pubkey count %d exceeds %d", count, MaxBuilderPubkeys) + } + *l.values = make([]common.Bytes48, count) + for i := range *l.values { + copy((*l.values)[i][:], buf[i*len(common.Bytes48{}):]) + } + return nil +} +func (l *builderPubkeyList) DecodeSSZStrict(buf []byte, version int) error { + return l.DecodeSSZ(buf, version) +} +func (l *builderPubkeyList) Clone() clonable.Clonable { + values := []common.Bytes48(nil) + return &builderPubkeyList{values: &values} +} + +type builderEntryList struct{ values *[]*BuilderEntry } + +func (l *builderEntryList) Static() bool { return false } +func (l *builderEntryList) EncodingSizeSSZ() int { + size := len(*l.values) * 4 + for _, entry := range *l.values { + if entry != nil { + size += entry.EncodingSizeSSZ() + } + } + return size +} +func (l *builderEntryList) EncodeSSZ(dst []byte) ([]byte, error) { + if len(*l.values) > MaxBuilderEntries { + return nil, fmt.Errorf("builder count %d exceeds %d", len(*l.values), MaxBuilderEntries) + } + for i, entry := range *l.values { + if entry == nil { + return nil, fmt.Errorf("builder %d is nil", i) + } + } + return commonssz.EncodeDynamicList(dst, *l.values) +} +func (l *builderEntryList) DecodeSSZ(buf []byte, version int) error { + values, err := commonssz.DecodeDynamicListStrict[*BuilderEntry](buf, 0, uint32(len(buf)), MaxBuilderEntries, version) + if err != nil { + return err + } + *l.values = values + return nil +} +func (l *builderEntryList) DecodeSSZStrict(buf []byte, version int) error { + return l.DecodeSSZ(buf, version) +} +func (l *builderEntryList) Clone() clonable.Clonable { + values := []*BuilderEntry(nil) + return &builderEntryList{values: &values} +} + +func validateBuilderAuthData(data []byte) error { + if len(data) == 0 || len(data) > MaxBuilderAuthDataSize { + return fmt.Errorf("builder auth data length %d is outside [1,%d]", len(data), MaxBuilderAuthDataSize) + } + return nil +} + +func validateBuilderURL(builderURL string) error { + if len(builderURL) == 0 || len(builderURL) > MaxBuilderURLSize { + return fmt.Errorf("builder URL length %d is outside [1,%d]", len(builderURL), MaxBuilderURLSize) + } + if !utf8.ValidString(builderURL) { + return errors.New("builder URL is not valid UTF-8") + } + parsed, err := url.Parse(builderURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil { + return errors.New("builder URL must be an HTTP(S) URL without user information") + } + return nil +} + +func decodeStrictJSON(data []byte, value any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return errors.New("JSON contains trailing data") + } + return nil +} diff --git a/cl/cltypes/epbs_builder_api_test.go b/cl/cltypes/epbs_builder_api_test.go new file mode 100644 index 00000000000..697aadf1cb6 --- /dev/null +++ b/cl/cltypes/epbs_builder_api_test.go @@ -0,0 +1,191 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package cltypes + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/hexutil" +) + +func validSignedBuilderRequestAuth() *SignedBuilderRequestAuth { + return &SignedBuilderRequestAuth{ + Message: &BuilderRequestAuth{Data: hexutil.Bytes("builder-auth"), Slot: 12}, + } +} + +func TestBuilderRequestAuthJSONAndSSZRoundTrip(t *testing.T) { + want := &BuilderRequestAuth{Data: hexutil.Bytes{0x12, 0x34}, Slot: 42} + + encodedJSON, err := json.Marshal(want) + require.NoError(t, err) + require.JSONEq(t, `{"data":"0x1234","slot":"42"}`, string(encodedJSON)) + var fromJSON BuilderRequestAuth + require.NoError(t, json.Unmarshal(encodedJSON, &fromJSON)) + require.Equal(t, want, &fromJSON) + + encodedSSZ, err := want.EncodeSSZ(nil) + require.NoError(t, err) + var fromSSZ BuilderRequestAuth + require.NoError(t, fromSSZ.DecodeSSZ(encodedSSZ, 0)) + require.Equal(t, want, &fromSSZ) +} + +func TestBuilderRequestAuthRejectsInvalidData(t *testing.T) { + for _, input := range []string{ + `{"data":"0x","slot":"1"}`, + `{"data":"0x01","slot":"1","extra":true}`, + `{"data":null,"slot":"1"}`, + `{"slot":"1"}`, + `{"data":"0x01"}`, + `{"data":"0x` + strings.Repeat("ab", MaxBuilderAuthDataSize+1) + `","slot":"1"}`, + } { + var auth BuilderRequestAuth + require.Error(t, json.Unmarshal([]byte(input), &auth), input) + } + + for _, data := range [][]byte{nil, make([]byte, MaxBuilderAuthDataSize+1)} { + auth := &BuilderRequestAuth{Data: data, Slot: 1} + _, err := auth.EncodeSSZ(nil) + require.Error(t, err) + } + var decoded BuilderRequestAuth + require.Error(t, decoded.DecodeSSZ(make([]byte, 12), 0)) +} + +func TestSignedBuilderRequestAuthRejectsNilMessage(t *testing.T) { + var auth SignedBuilderRequestAuth + _, err := auth.EncodeSSZ(nil) + require.Error(t, err) + require.Error(t, json.Unmarshal([]byte(`{"message":null,"signature":"0x`+strings.Repeat("00", 96)+`"}`), &auth)) +} + +func TestBuilderEntryJSONAndSSZRoundTrip(t *testing.T) { + entry := &BuilderEntry{ + URL: "https://builder.example", + Auth: validSignedBuilderRequestAuth(), + BuilderPubkeys: []common.Bytes48{{1}, {2}}, + MaxExecutionPayment: 20, + MinBid: 10, + BuilderBoostFactor: 100, + } + + encodedJSON, err := json.Marshal(entry) + require.NoError(t, err) + var fromJSON BuilderEntry + require.NoError(t, json.Unmarshal(encodedJSON, &fromJSON)) + require.Equal(t, entry, &fromJSON) + + encodedSSZ, err := entry.EncodeSSZ(nil) + require.NoError(t, err) + var fromSSZ BuilderEntry + require.NoError(t, fromSSZ.DecodeSSZ(encodedSSZ, 0)) + require.Equal(t, entry, &fromSSZ) +} + +func TestBuilderEntryRejectsInvalidFields(t *testing.T) { + valid, err := json.Marshal(&BuilderEntry{ + URL: "https://builder.example", + Auth: validSignedBuilderRequestAuth(), + BuilderPubkeys: []common.Bytes48{}, + MaxExecutionPayment: 20, + MinBid: 10, + BuilderBoostFactor: 100, + }) + require.NoError(t, err) + + for _, mutate := range []func(map[string]any){ + func(v map[string]any) { v["url"] = "" }, + func(v map[string]any) { v["url"] = strings.Repeat("x", MaxBuilderURLSize+1) }, + func(v map[string]any) { v["auth"] = nil }, + func(v map[string]any) { delete(v, "builder_pubkeys") }, + func(v map[string]any) { v["builder_pubkeys"] = make([]common.Bytes48, MaxBuilderPubkeys+1) }, + func(v map[string]any) { v["unexpected"] = true }, + } { + var value map[string]any + require.NoError(t, json.Unmarshal(valid, &value)) + mutate(value) + input, err := json.Marshal(value) + require.NoError(t, err) + var entry BuilderEntry + require.Error(t, json.Unmarshal(input, &entry), string(input)) + } +} + +func TestBuilderConfigAndPreferencesLimits(t *testing.T) { + configJSON := `{"min_bid":"0","builder_boost_factor":"100","builders":[]}` + var config BuilderConfig + require.NoError(t, json.Unmarshal([]byte(configJSON), &config)) + encoded, err := config.EncodeSSZ(nil) + require.NoError(t, err) + var decoded BuilderConfig + require.NoError(t, decoded.DecodeSSZ(encoded, 0)) + require.Equal(t, &config, &decoded) + + config.Builders = make([]*BuilderEntry, MaxBuilderEntries+1) + _, err = config.EncodeSSZ(nil) + require.Error(t, err) + + prefs := &BuilderPreferencesRequest{ + Preferences: &BuilderPreferences{MaxExecutionPayment: 11}, + Auth: validSignedBuilderRequestAuth(), + } + encoded, err = prefs.EncodeSSZ(nil) + require.NoError(t, err) + var decodedPrefs BuilderPreferencesRequest + require.NoError(t, decodedPrefs.DecodeSSZ(encoded, 0)) + require.Equal(t, prefs, &decodedPrefs) + require.Error(t, json.Unmarshal([]byte(`{"preferences":null,"auth":null}`), &decodedPrefs)) +} + +func TestBuilderPreferencesEntryRejectsEmptyURL(t *testing.T) { + entry := &BuilderPreferencesEntry{Auth: validSignedBuilderRequestAuth()} + _, err := entry.EncodeSSZ(nil) + require.Error(t, err) +} + +func TestBuilderPreferencesEntriesJSONAndSSZ(t *testing.T) { + want := BuilderPreferencesEntries{ + &BuilderPreferencesEntry{ + ProposerPubkey: common.Bytes48{1}, + URL: "https://builder.example", + Auth: validSignedBuilderRequestAuth(), + MaxExecutionPayment: 15, + }, + } + encodedJSON, err := json.Marshal(want) + require.NoError(t, err) + var fromJSON BuilderPreferencesEntries + require.NoError(t, json.Unmarshal(encodedJSON, &fromJSON)) + require.Equal(t, want, fromJSON) + + encodedSSZ, err := want.EncodeSSZ(nil) + require.NoError(t, err) + var fromSSZ BuilderPreferencesEntries + require.NoError(t, fromSSZ.DecodeSSZ(encodedSSZ, 0)) + require.Equal(t, want, fromSSZ) + + tooMany := make(BuilderPreferencesEntries, MaxBuilderPreferencesEntries+1) + _, err = tooMany.EncodeSSZ(nil) + require.Error(t, err) + require.Error(t, json.Unmarshal([]byte("null"), &fromJSON)) +} diff --git a/cl/cltypes/epbs_payload.go b/cl/cltypes/epbs_payload.go index 857b003e234..af375dd66ba 100644 --- a/cl/cltypes/epbs_payload.go +++ b/cl/cltypes/epbs_payload.go @@ -367,6 +367,25 @@ func (e *ExecutionPayloadBid) DecodeSSZ(buf []byte, version int) error { ) } +func (e *ExecutionPayloadBid) DecodeSSZStrict(buf []byte, version int) error { + e.BlobKzgCommitments.EnsureStaticProgressive(maxBlobCommitmentsForConfig(clparams.GetBeaconConfig()), 48) + return ssz2.UnmarshalSSZStrict( + buf, version, + e.ParentBlockHash[:], + e.ParentBlockRoot[:], + e.BlockHash[:], + e.PrevRandao[:], + e.FeeRecipient[:], + &e.GasLimit, + &e.BuilderIndex, + &e.Slot, + &e.Value, + &e.ExecutionPayment, + &e.BlobKzgCommitments, + e.ExecutionRequestsRoot[:], + ) +} + func (e *ExecutionPayloadBid) Clone() clonable.Clonable { commitments := e.BlobKzgCommitments.Clone().(*solid.ListSSZ[*KZGCommitment]) commitments.EnsureStaticProgressive(maxBlobCommitmentsForConfig(clparams.GetBeaconConfig()), 48) @@ -440,6 +459,15 @@ func (s *SignedExecutionPayloadBid) DecodeSSZ(buf []byte, version int) error { return ssz2.UnmarshalSSZ(buf, version, s.Message, s.Signature[:]) } +func (s *SignedExecutionPayloadBid) DecodeSSZStrict(buf []byte, version int) error { + if s.Message == nil { + s.Message = &ExecutionPayloadBid{ + BlobKzgCommitments: *solid.NewStaticProgressiveListSSZ[*KZGCommitment](maxBlobCommitmentsForConfig(clparams.GetBeaconConfig()), 48), + } + } + return ssz2.UnmarshalSSZStrict(buf, version, s.Message, s.Signature[:]) +} + func (s *SignedExecutionPayloadBid) Clone() clonable.Clonable { return &SignedExecutionPayloadBid{ Message: s.Message.Clone().(*ExecutionPayloadBid), @@ -495,12 +523,7 @@ func (e *ExecutionPayloadEnvelope) EncodeSSZ(buf []byte) ([]byte, error) { } func (e *ExecutionPayloadEnvelope) DecodeSSZ(buf []byte, version int) error { - if e.Payload == nil { - e.Payload = NewEth1Block(clparams.StateVersion(version), e.beaconCfg) - } - if e.ExecutionRequests == nil { - e.ExecutionRequests = NewExecutionRequestsWithVersion(e.beaconCfg, clparams.StateVersion(version)) - } + e.initializeDecodeFields(version) return ssz2.UnmarshalSSZ( buf, version, e.Payload, @@ -511,6 +534,27 @@ func (e *ExecutionPayloadEnvelope) DecodeSSZ(buf []byte, version int) error { ) } +func (e *ExecutionPayloadEnvelope) DecodeSSZStrict(buf []byte, version int) error { + e.initializeDecodeFields(version) + return ssz2.UnmarshalSSZStrict( + buf, version, + e.Payload, + e.ExecutionRequests, + &e.BuilderIndex, + e.BeaconBlockRoot[:], + e.ParentBeaconBlockRoot[:], + ) +} + +func (e *ExecutionPayloadEnvelope) initializeDecodeFields(version int) { + if e.Payload == nil { + e.Payload = NewEth1Block(clparams.StateVersion(version), e.beaconCfg) + } + if e.ExecutionRequests == nil { + e.ExecutionRequests = NewExecutionRequestsWithVersion(e.beaconCfg, clparams.StateVersion(version)) + } +} + func (e *ExecutionPayloadEnvelope) EncodingSizeSSZ() int { // Payload and ExecutionRequests are dynamic, so each contributes a 4-byte offset. const dynamicOffsetSize = 4 @@ -565,6 +609,13 @@ func (s *SignedExecutionPayloadEnvelope) DecodeSSZ(buf []byte, version int) erro return ssz2.UnmarshalSSZ(buf, version, s.Message, s.Signature[:]) } +func (s *SignedExecutionPayloadEnvelope) DecodeSSZStrict(buf []byte, version int) error { + if s.Message == nil { + s.Message = NewExecutionPayloadEnvelope(s.beaconCfg) + } + return ssz2.UnmarshalSSZStrict(buf, version, s.Message, s.Signature[:]) +} + func (s *SignedExecutionPayloadEnvelope) EncodingSizeSSZ() int { return signedDynamicSize(s.Message.EncodingSizeSSZ()) } diff --git a/cl/cltypes/execution_requests.go b/cl/cltypes/execution_requests.go index 5ddfb690aad..88fc7f1279a 100644 --- a/cl/cltypes/execution_requests.go +++ b/cl/cltypes/execution_requests.go @@ -111,6 +111,22 @@ func (e *ExecutionRequests) EncodeSSZ(buf []byte) ([]byte, error) { } func (e *ExecutionRequests) DecodeSSZ(buf []byte, version int) error { + e.initializeDecodeLists(version) + if e.effectiveVersion() < clparams.GloasVersion { + return ssz2.UnmarshalSSZ(buf, version, e.Deposits, e.Withdrawals, e.Consolidations) + } + return ssz2.UnmarshalSSZ(buf, version, e.Deposits, e.Withdrawals, e.Consolidations, e.BuilderDeposits, e.BuilderExits) +} + +func (e *ExecutionRequests) DecodeSSZStrict(buf []byte, version int) error { + e.initializeDecodeLists(version) + if e.effectiveVersion() < clparams.GloasVersion { + return ssz2.UnmarshalSSZStrict(buf, version, e.Deposits, e.Withdrawals, e.Consolidations) + } + return ssz2.UnmarshalSSZStrict(buf, version, e.Deposits, e.Withdrawals, e.Consolidations, e.BuilderDeposits, e.BuilderExits) +} + +func (e *ExecutionRequests) initializeDecodeLists(version int) { decodedVersion := clparams.StateVersion(version) if (e.effectiveVersion() >= clparams.GloasVersion) != (decodedVersion >= clparams.GloasVersion) { e.Deposits = nil @@ -121,10 +137,6 @@ func (e *ExecutionRequests) DecodeSSZ(buf []byte, version int) error { } e.version = decodedVersion e.ensureLists() - if e.effectiveVersion() < clparams.GloasVersion { - return ssz2.UnmarshalSSZ(buf, version, e.Deposits, e.Withdrawals, e.Consolidations) - } - return ssz2.UnmarshalSSZ(buf, version, e.Deposits, e.Withdrawals, e.Consolidations, e.BuilderDeposits, e.BuilderExits) } func (e *ExecutionRequests) Clone() clonable.Clonable { diff --git a/cl/cltypes/gloas_api.go b/cl/cltypes/gloas_api.go new file mode 100644 index 00000000000..27c0e8aa25a --- /dev/null +++ b/cl/cltypes/gloas_api.go @@ -0,0 +1,90 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package cltypes + +import ( + "github.com/erigontech/erigon/cl/clparams" + "github.com/erigontech/erigon/cl/cltypes/solid" + ssz2 "github.com/erigontech/erigon/cl/ssz" +) + +type GloasBlockContents struct { + Block *BeaconBlock `json:"block"` + ExecutionPayloadEnvelope *ExecutionPayloadEnvelope `json:"execution_payload_envelope"` + KZGProofs *solid.ListSSZ[*KZGProof] `json:"kzg_proofs"` + Blobs *solid.ListSSZ[*Blob] `json:"blobs"` +} + +func NewGloasBlockContents(cfg *clparams.BeaconChainConfig, slot uint64) *GloasBlockContents { + maxBlobs := int(cfg.GetBlobParameters(slot / cfg.SlotsPerEpoch).MaxBlobsPerBlock) + return &GloasBlockContents{ + Block: NewBeaconBlock(cfg, clparams.GloasVersion), + ExecutionPayloadEnvelope: NewExecutionPayloadEnvelope(cfg), + KZGProofs: solid.NewStaticListSSZ[*KZGProof](maxBlobs*int(cfg.NumberOfColumns), BYTES_KZG_PROOF), + Blobs: solid.NewStaticListSSZ[*Blob](maxBlobs, int(BYTES_PER_BLOB)), + } +} + +func (b *GloasBlockContents) EncodeSSZ(buf []byte) ([]byte, error) { + return ssz2.MarshalSSZ(buf, b.Block, b.ExecutionPayloadEnvelope, b.KZGProofs, b.Blobs) +} + +func (b *GloasBlockContents) DecodeSSZ(buf []byte, version int) error { + return ssz2.UnmarshalSSZ(buf, version, b.Block, b.ExecutionPayloadEnvelope, b.KZGProofs, b.Blobs) +} + +func (b *GloasBlockContents) EncodingSizeSSZ() int { + return b.Block.EncodingSizeSSZ() + b.ExecutionPayloadEnvelope.EncodingSizeSSZ() + b.KZGProofs.EncodingSizeSSZ() + b.Blobs.EncodingSizeSSZ() +} + +func (b *GloasBlockContents) Static() bool { return false } + +type SignedExecutionPayloadEnvelopeContents struct { + SignedExecutionPayloadEnvelope *SignedExecutionPayloadEnvelope `json:"signed_execution_payload_envelope"` + KZGProofs *solid.ListSSZ[*KZGProof] `json:"kzg_proofs"` + Blobs *solid.ListSSZ[*Blob] `json:"blobs"` +} + +func NewSignedExecutionPayloadEnvelopeContents(cfg *clparams.BeaconChainConfig, slot uint64) *SignedExecutionPayloadEnvelopeContents { + maxBlobs := int(cfg.GetBlobParameters(slot / cfg.SlotsPerEpoch).MaxBlobsPerBlock) + return &SignedExecutionPayloadEnvelopeContents{ + SignedExecutionPayloadEnvelope: &SignedExecutionPayloadEnvelope{ + Message: NewExecutionPayloadEnvelope(cfg), + beaconCfg: cfg, + }, + KZGProofs: solid.NewStaticListSSZ[*KZGProof](maxBlobs*int(cfg.NumberOfColumns), BYTES_KZG_PROOF), + Blobs: solid.NewStaticListSSZ[*Blob](maxBlobs, int(BYTES_PER_BLOB)), + } +} + +func (c *SignedExecutionPayloadEnvelopeContents) EncodeSSZ(buf []byte) ([]byte, error) { + return ssz2.MarshalSSZ(buf, c.SignedExecutionPayloadEnvelope, c.KZGProofs, c.Blobs) +} + +func (c *SignedExecutionPayloadEnvelopeContents) DecodeSSZ(buf []byte, version int) error { + return ssz2.UnmarshalSSZ(buf, version, c.SignedExecutionPayloadEnvelope, c.KZGProofs, c.Blobs) +} + +func (c *SignedExecutionPayloadEnvelopeContents) DecodeSSZStrict(buf []byte, version int) error { + return ssz2.UnmarshalSSZStrict(buf, version, c.SignedExecutionPayloadEnvelope, c.KZGProofs, c.Blobs) +} + +func (c *SignedExecutionPayloadEnvelopeContents) EncodingSizeSSZ() int { + return c.SignedExecutionPayloadEnvelope.EncodingSizeSSZ() + c.KZGProofs.EncodingSizeSSZ() + c.Blobs.EncodingSizeSSZ() +} + +func (c *SignedExecutionPayloadEnvelopeContents) Static() bool { return false } diff --git a/cl/cltypes/gloas_api_test.go b/cl/cltypes/gloas_api_test.go new file mode 100644 index 00000000000..044a5fbd5e1 --- /dev/null +++ b/cl/cltypes/gloas_api_test.go @@ -0,0 +1,131 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package cltypes + +import ( + "encoding/binary" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cl/clparams" + "github.com/erigontech/erigon/cl/cltypes/solid" +) + +func TestGloasBlockContentsSSZRoundTrip(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + contents := NewGloasBlockContents(&cfg, 64) + contents.Block.Slot = 64 + contents.ExecutionPayloadEnvelope.BuilderIndex = 7 + + encoded, err := contents.EncodeSSZ(nil) + require.NoError(t, err) + + decoded := NewGloasBlockContents(&cfg, 64) + require.NoError(t, decoded.DecodeSSZ(encoded, int(clparams.GloasVersion))) + require.Equal(t, uint64(64), decoded.Block.Slot) + require.Equal(t, uint64(7), decoded.ExecutionPayloadEnvelope.BuilderIndex) + require.Equal(t, encoded, requireEncodedSSZ(t, decoded)) +} + +func TestSignedExecutionPayloadEnvelopeContentsSSZRoundTrip(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + contents := NewSignedExecutionPayloadEnvelopeContents(&cfg, 64) + contents.SignedExecutionPayloadEnvelope.Message.BuilderIndex = 9 + + encoded, err := contents.EncodeSSZ(nil) + require.NoError(t, err) + + decoded := NewSignedExecutionPayloadEnvelopeContents(&cfg, 64) + require.NoError(t, decoded.DecodeSSZ(encoded, int(clparams.GloasVersion))) + require.Equal(t, uint64(9), decoded.SignedExecutionPayloadEnvelope.Message.BuilderIndex) + require.Equal(t, encoded, requireEncodedSSZ(t, decoded)) +} + +func TestSignedExecutionPayloadEnvelopeContentsRejectsNonCanonicalSSZOffsets(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + contents := NewSignedExecutionPayloadEnvelopeContents(&cfg, 64) + encoded, err := contents.EncodeSSZ(nil) + require.NoError(t, err) + nonCanonical := append([]byte(nil), encoded[:12]...) + nonCanonical = append(nonCanonical, make([]byte, 4)...) + nonCanonical = append(nonCanonical, encoded[12:]...) + for offset := 0; offset < 12; offset += 4 { + binary.LittleEndian.PutUint32(nonCanonical[offset:], binary.LittleEndian.Uint32(encoded[offset:])+4) + } + require.NoError(t, NewSignedExecutionPayloadEnvelopeContents(&cfg, 64).DecodeSSZ(nonCanonical, int(clparams.GloasVersion))) + require.Error(t, NewSignedExecutionPayloadEnvelopeContents(&cfg, 64).DecodeSSZStrict(nonCanonical, int(clparams.GloasVersion))) +} + +func TestSignedExecutionPayloadEnvelopeRejectsNonCanonicalSSZOffsets(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + envelope := &SignedExecutionPayloadEnvelope{Message: NewExecutionPayloadEnvelope(&cfg), beaconCfg: &cfg} + encoded, err := envelope.EncodeSSZ(nil) + require.NoError(t, err) + const fixedSize = 4 + 96 + nonCanonical := append([]byte(nil), encoded[:fixedSize]...) + nonCanonical = append(nonCanonical, make([]byte, 4)...) + nonCanonical = append(nonCanonical, encoded[fixedSize:]...) + binary.LittleEndian.PutUint32(nonCanonical, binary.LittleEndian.Uint32(encoded)+4) + decoded := &SignedExecutionPayloadEnvelope{Message: NewExecutionPayloadEnvelope(&cfg), beaconCfg: &cfg} + require.NoError(t, decoded.DecodeSSZ(nonCanonical, int(clparams.GloasVersion))) + strict := &SignedExecutionPayloadEnvelope{Message: NewExecutionPayloadEnvelope(&cfg), beaconCfg: &cfg} + require.Error(t, strict.DecodeSSZStrict(nonCanonical, int(clparams.GloasVersion))) +} + +func TestExecutionRequestsRejectsNonCanonicalSSZOffsets(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + requests := NewExecutionRequestsWithVersion(&cfg, clparams.GloasVersion) + encoded, err := requests.EncodeSSZ(nil) + require.NoError(t, err) + const fixedSize = 5 * 4 + nonCanonical := append([]byte(nil), encoded[:fixedSize]...) + nonCanonical = append(nonCanonical, make([]byte, 4)...) + nonCanonical = append(nonCanonical, encoded[fixedSize:]...) + for offset := 0; offset < fixedSize; offset += 4 { + binary.LittleEndian.PutUint32(nonCanonical[offset:], binary.LittleEndian.Uint32(encoded[offset:])+4) + } + require.NoError(t, NewExecutionRequestsWithVersion(&cfg, clparams.GloasVersion).DecodeSSZ(nonCanonical, int(clparams.GloasVersion))) + require.Error(t, NewExecutionRequestsWithVersion(&cfg, clparams.GloasVersion).DecodeSSZStrict(nonCanonical, int(clparams.GloasVersion))) +} + +func TestExecutionPayloadBidRejectsNonCanonicalSSZOffsets(t *testing.T) { + bid := &ExecutionPayloadBid{ + BlobKzgCommitments: *solid.NewStaticProgressiveListSSZ[*KZGCommitment](MaxBlobsCommittmentsPerBlock, 48), + } + encoded, err := bid.EncodeSSZ(nil) + require.NoError(t, err) + const fixedSize = 224 + nonCanonical := append([]byte(nil), encoded[:fixedSize]...) + nonCanonical = append(nonCanonical, make([]byte, 4)...) + nonCanonical = append(nonCanonical, encoded[fixedSize:]...) + const commitmentsOffsetPosition = 188 + binary.LittleEndian.PutUint32(nonCanonical[commitmentsOffsetPosition:], binary.LittleEndian.Uint32(encoded[commitmentsOffsetPosition:])+4) + loose := &ExecutionPayloadBid{BlobKzgCommitments: *solid.NewStaticProgressiveListSSZ[*KZGCommitment](MaxBlobsCommittmentsPerBlock, 48)} + require.NoError(t, loose.DecodeSSZ(nonCanonical, int(clparams.GloasVersion))) + strict := &ExecutionPayloadBid{BlobKzgCommitments: *solid.NewStaticProgressiveListSSZ[*KZGCommitment](MaxBlobsCommittmentsPerBlock, 48)} + require.Error(t, strict.DecodeSSZStrict(nonCanonical, int(clparams.GloasVersion))) +} + +func requireEncodedSSZ(t *testing.T, value interface { + EncodeSSZ([]byte) ([]byte, error) +}) []byte { + t.Helper() + encoded, err := value.EncodeSSZ(nil) + require.NoError(t, err) + return encoded +} diff --git a/cl/phase1/network/services/execution_payload_bid_service.go b/cl/phase1/network/services/execution_payload_bid_service.go index d59aff89d73..2d466adb100 100644 --- a/cl/phase1/network/services/execution_payload_bid_service.go +++ b/cl/phase1/network/services/execution_payload_bid_service.go @@ -18,6 +18,7 @@ package services import ( "context" + "errors" "fmt" "math" "sync" @@ -289,6 +290,56 @@ func (s *executionPayloadBidService) ProcessMessage(ctx context.Context, _ *uint return nil } +func (s *executionPayloadBidService) ValidateBid(_ context.Context, msg *cltypes.SignedExecutionPayloadBid) error { + if msg == nil || msg.Message == nil { + return errors.New("nil execution payload bid message") + } + bid := msg.Message + if !isCurrentOrNextSlot(s.ethClock, s.beaconCfg, s.now(), bid.Slot, gloasMaximumClockDisparity) { + return fmt.Errorf("%w: bid slot %d is not current or next slot", ErrIgnore, bid.Slot) + } + parentHeader, ok := s.forkchoiceStore.GetHeader(bid.ParentBlockRoot) + if !ok { + return fmt.Errorf("%w: parent_block_root %v not known in fork choice", ErrIgnore, bid.ParentBlockRoot) + } + if bid.Slot <= parentHeader.Slot { + return fmt.Errorf("bid slot %d is not greater than parent block slot %d", bid.Slot, parentHeader.Slot) + } + maxBlobs := int(s.beaconCfg.GetBlobParameters(state.GetEpochAtSlot(s.beaconCfg, bid.Slot)).MaxBlobsPerBlock) + if bid.BlobKzgCommitments.Len() > maxBlobs { + return fmt.Errorf("bid has too many blob_kzg_commitments: %d > %d", bid.BlobKzgCommitments.Len(), maxBlobs) + } + preferences, ok, err := s.matchingProposerPreferences(msg) + if err != nil { + return err + } + if !ok || preferences.Message == nil { + return fmt.Errorf("%w: proposer preferences not available", ErrIgnore) + } + if bid.FeeRecipient != preferences.Message.FeeRecipient { + return fmt.Errorf("%w: bid fee recipient does not match proposer preferences", ErrIgnore) + } + if _, ok := s.forkchoiceStore.GetRecentExecutionPayloadStatus(bid.ParentBlockHash); !ok { + return fmt.Errorf("%w: parent_block_hash %v not known in fork choice", ErrIgnore, bid.ParentBlockHash) + } + parentGasLimit, ok := s.forkchoiceStore.GetExecutionPayloadGasLimit(bid.ParentBlockHash) + if !ok || !IsGasLimitTargetCompatible(parentGasLimit, bid.GasLimit, preferences.Message.TargetGasLimit) { + return fmt.Errorf("%w: bid gas limit is not compatible with proposer preferences", ErrIgnore) + } + compatible, err := s.isBidCompatibleWithHead(bid) + if err != nil { + return err + } + if !compatible { + return fmt.Errorf("%w: bid is not compatible with the current head branch", ErrIgnore) + } + validationStateEntry, err := s.bidValidationState(bid.ParentBlockRoot, bid.Slot) + if err != nil { + return err + } + return s.validateBidAuthentication(msg, validationStateEntry) +} + func isCurrentOrNextSlot(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, now time.Time, slot uint64, disparity time.Duration) bool { if slot == ^uint64(0) { return false diff --git a/cl/phase1/network/services/execution_payload_bid_service_test.go b/cl/phase1/network/services/execution_payload_bid_service_test.go index 45d71f0b19e..9c7128fa74e 100644 --- a/cl/phase1/network/services/execution_payload_bid_service_test.go +++ b/cl/phase1/network/services/execution_payload_bid_service_test.go @@ -248,6 +248,7 @@ func TestExecutionPayloadBidServiceOrdersHighestBeforeStatelessChecks(t *testing ctrl := gomock.NewController(t) service, _, ethClockMock, _, epbsPool := setupExecutionPayloadBidService(t, ctrl) msg := newTestSignedExecutionPayloadBid(100, 1, 1) + msg.Message.ExecutionPayment = 10 msg.Message.ExecutionPayment = 1 epbsPool.HighestBids.Add(pool.HighestBidKey{ Slot: msg.Message.Slot, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot, @@ -276,6 +277,21 @@ func TestExecutionPayloadBidServiceAuthenticatesAcceptedBidOnce(t *testing.T) { require.Equal(t, 1, calls) } +func TestValidateDirectBidDoesNotApplyGossipHighestFilter(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl) + msg := newTestSignedExecutionPayloadBid(100, 1, 1) + addPreferencesToPool(epbsPool, 100) + fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated + epbsPool.HighestBids.Add(pool.HighestBidKey{ + Slot: msg.Message.Slot, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot, + }, newTestSignedExecutionPayloadBid(100, 2, 2)) + ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)).Times(2) + + require.NoError(t, service.ValidateBid(context.Background(), msg)) + require.Error(t, service.ProcessMessage(context.Background(), nil, msg)) +} + func TestExecutionPayloadBidServiceWrongSlot(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/cl/phase1/network/services/execution_payload_service.go b/cl/phase1/network/services/execution_payload_service.go index 086b85a9475..23a86904b43 100644 --- a/cl/phase1/network/services/execution_payload_service.go +++ b/cl/phase1/network/services/execution_payload_service.go @@ -191,6 +191,16 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64, // This ensures invalid envelopes (e.g., with forged signatures) don't block valid ones s.seenEnvelopesCache.Add(seenKey, struct{}{}) + payloadEvent := &beaconevents.ExecutionPayloadGossipData{ + Slot: block.Block.Slot, BuilderIndex: builderIndex, BlockHash: envelope.Payload.BlockHash, BlockRoot: beaconBlockRoot, + } + s.emitters.Operation().SendExecutionPayloadGossip(payloadEvent) + s.emitters.Operation().SendExecutionPayload(&beaconevents.ExecutionPayloadData{ + Slot: block.Block.Slot, BuilderIndex: builderIndex, BlockHash: envelope.Payload.BlockHash, BlockRoot: beaconBlockRoot, + ExecutionOptimistic: s.forkchoiceStore.IsRootOptimistic(beaconBlockRoot), + }) + s.emitFullHeadUpdate(block, beaconBlockRoot) + // Emit SSE event for execution_payload_available [New in Gloas:EIP7732] s.emitters.Operation().SendExecutionPayloadAvailable(&beaconevents.ExecutionPayloadAvailableData{ Slot: block.Block.Slot, @@ -205,6 +215,40 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64, return nil } +func (s *executionPayloadService) emitFullHeadUpdate(block *cltypes.SignedBeaconBlock, blockRoot common.Hash) { + headRoot, headSlot, err := s.forkchoiceStore.GetHead(nil) + if err != nil || headRoot != blockRoot || s.beaconCfg.SlotsPerEpoch == 0 { + return + } + headState, err := s.forkchoiceStore.GetStateAtBlockRoot(blockRoot, true) + if err != nil || headState == nil { + return + } + epoch := headSlot / s.beaconCfg.SlotsPerEpoch + currentDependentRoot := s.forkchoiceStore.AnchorRoot() + nextDependentRoot := s.forkchoiceStore.AnchorRoot() + if epoch > 1 { + currentDependentRoot, err = headState.GetBlockRootAtSlot((epoch-1)*s.beaconCfg.SlotsPerEpoch - 1) + if err != nil { + return + } + } + if epoch > 0 { + nextDependentRoot, err = headState.GetBlockRootAtSlot(epoch*s.beaconCfg.SlotsPerEpoch - 1) + if err != nil { + return + } + } + s.emitters.State().SendHeadV2(&beaconevents.HeadV2Data{ + Version: clparams.GloasVersion.String(), + Data: beaconevents.HeadV2Content{ + Slot: headSlot, Block: headRoot, State: block.Block.StateRoot, PayloadStatus: "full", + EpochTransition: headSlot%s.beaconCfg.SlotsPerEpoch == 0, CurrentEpochDependentRoot: currentDependentRoot, + NextEpochDependentRoot: nextDependentRoot, ExecutionOptimistic: s.forkchoiceStore.IsRootOptimistic(blockRoot), + }, + }) +} + // queuePendingEnvelope adds an envelope to the pending queue for later processing func (s *executionPayloadService) queuePendingEnvelope(blockRoot common.Hash, envelope *cltypes.SignedExecutionPayloadEnvelope) { if s.pendingCount.Add(1) > maxPendingEnvelopes { diff --git a/cl/phase1/network/services/execution_payload_service_test.go b/cl/phase1/network/services/execution_payload_service_test.go index 6a64ffc2326..da54f5c2330 100644 --- a/cl/phase1/network/services/execution_payload_service_test.go +++ b/cl/phase1/network/services/execution_payload_service_test.go @@ -29,6 +29,7 @@ import ( "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" + "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/phase1/core/state/lru" "github.com/erigontech/erigon/cl/phase1/forkchoice/mock_services" "github.com/erigontech/erigon/common" @@ -98,6 +99,45 @@ func TestExecutionPayloadServiceBlockNotFound(t *testing.T) { require.NoError(t, err) } +func TestExecutionPayloadServiceEmitsGossipAndImportedEvents(t *testing.T) { + cfg := &clparams.MainnetBeaconConfig + forkchoiceMock := mock_services.NewForkChoiceStorageMock(t) + emitter := beaconevents.NewEventEmitter() + service := NewExecutionPayloadService(t.Context(), forkchoiceMock, cfg, emitter) + events := make(chan *beaconevents.EventStream, 3) + subscription := emitter.Operation().Subscribe(events) + defer subscription.Unsubscribe() + stateEvents := make(chan *beaconevents.EventStream, 1) + stateSubscription := emitter.State().Subscribe(stateEvents) + defer stateSubscription.Unsubscribe() + + blockRoot := common.Hash{1} + stateRoot := common.Hash{2} + forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100, StateRoot: stateRoot}} + headState := state.New(cfg) + headState.SetVersion(clparams.GloasVersion) + headState.SetSlot(100) + headState.SetBlockRootAt(63, common.Hash{3}) + headState.SetBlockRootAt(95, common.Hash{4}) + forkchoiceMock.GetStateAtBlockRootFn = func(root common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) { + require.Equal(t, blockRoot, root) + require.True(t, alwaysCopy) + return headState, nil + } + forkchoiceMock.HeadVal = blockRoot + forkchoiceMock.HeadSlotVal = 100 + envelope := newTestSignedEnvelope(100, blockRoot, 7) + require.NoError(t, service.ProcessMessage(t.Context(), nil, envelope)) + + require.Equal(t, beaconevents.OpExecutionPayloadGossip, (<-events).Event) + require.Equal(t, beaconevents.OpExecutionPayload, (<-events).Event) + require.Equal(t, beaconevents.OpExecutionPayloadAvailable, (<-events).Event) + headEvent := <-stateEvents + require.Equal(t, beaconevents.StateHeadV2, headEvent.Event) + require.Equal(t, "full", headEvent.Data.(*beaconevents.HeadV2Data).Data.PayloadStatus) + require.Equal(t, blockRoot, headEvent.Data.(*beaconevents.HeadV2Data).Data.Block) +} + func TestExecutionPayloadServiceAlreadySeen(t *testing.T) { service, fcu := setupExecutionPayloadService(t) diff --git a/cl/phase1/network/services/proposer_preferences_service.go b/cl/phase1/network/services/proposer_preferences_service.go index fb55e90c4ac..25b17a49ca7 100644 --- a/cl/phase1/network/services/proposer_preferences_service.go +++ b/cl/phase1/network/services/proposer_preferences_service.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "github.com/erigontech/erigon/cl/beacon/beaconevents" "github.com/erigontech/erigon/cl/beacon/synced_data" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" @@ -36,6 +37,7 @@ type proposerPreferencesService struct { beaconCfg *clparams.BeaconChainConfig epbsPool *pool.EpbsPool now func() time.Time + emitters *beaconevents.EventEmitter storeMu sync.Mutex } @@ -48,6 +50,7 @@ func NewProposerPreferencesService( ethClock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, epbsPool *pool.EpbsPool, + emitters *beaconevents.EventEmitter, ) ProposerPreferencesService { return &proposerPreferencesService{ syncedDataManager: syncedDataManager, @@ -56,6 +59,7 @@ func NewProposerPreferencesService( beaconCfg: beaconCfg, epbsPool: epbsPool, now: time.Now, + emitters: emitters, } } @@ -154,6 +158,9 @@ func (s *proposerPreferencesService) ProcessMessage(ctx context.Context, _ *uint Slot: proposalSlot, DependentRoot: preferences.DependentRoot, }, msg) + if s.emitters != nil { + s.emitters.Operation().SendProposerPreferences(&beaconevents.VersionedSignedProposerPreferences{Version: clparams.GloasVersion.String(), Data: msg}) + } log.Trace("Processed proposer preferences via gossip", "proposalSlot", proposalSlot, diff --git a/cl/phase1/network/services/proposer_preferences_service_test.go b/cl/phase1/network/services/proposer_preferences_service_test.go index f28866605b1..0a56332f81b 100644 --- a/cl/phase1/network/services/proposer_preferences_service_test.go +++ b/cl/phase1/network/services/proposer_preferences_service_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "github.com/erigontech/erigon/cl/beacon/beaconevents" synced_data_mock "github.com/erigontech/erigon/cl/beacon/synced_data/mock_services" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" @@ -263,6 +264,23 @@ func TestProposerPreferencesServiceAcceptsEpochRolloverDisparityEdge(t *testing. require.Same(t, msg, stored) } +func TestProposerPreferencesServiceEmitsEvent(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, _, _, _ := setupProposerPreferencesService(t, ctrl) + emitter := beaconevents.NewEventEmitter() + service.emitters = emitter + events := make(chan *beaconevents.EventStream, 1) + subscription := emitter.Operation().Subscribe(events) + defer subscription.Unsubscribe() + msg := newTestSignedProposerPreferences(96, 42) + service.now = func() time.Time { return service.ethClock.GetSlotTime(96).Add(gloasMaximumClockDisparity) } + + require.NoError(t, service.ProcessMessage(context.Background(), nil, msg)) + event := <-events + require.Equal(t, beaconevents.OpProposerPreferences, event.Event) + require.Equal(t, &beaconevents.VersionedSignedProposerPreferences{Version: "gloas", Data: msg}, event.Data) +} + func TestProposerPreferencesServiceLookaheadClockDisparityBoundary(t *testing.T) { ctrl := gomock.NewController(t) service, _, _, epbsPool, _ := setupProposerPreferencesService(t, ctrl) diff --git a/cl/phase1/stages/forkchoice.go b/cl/phase1/stages/forkchoice.go index f369bd81063..6312074bf9a 100644 --- a/cl/phase1/stages/forkchoice.go +++ b/cl/phase1/stages/forkchoice.go @@ -217,13 +217,20 @@ func updateCanonicalChainInTheDatabase(ctx context.Context, tx kv.RwTx, headSlot // emitHeadEvent emits the head event with the given head slot, head root, and head state. func emitHeadEvent(cfg *Cfg, headSlot uint64, headRoot common.Hash, headState *state.CachingBeaconState) error { headEpoch := headSlot / cfg.beaconCfg.SlotsPerEpoch - previous_duty_dependent_root, err := headState.GetBlockRootAtSlot((headEpoch-1)*cfg.beaconCfg.SlotsPerEpoch - 1) - if err != nil { - return fmt.Errorf("failed to get block root at slot for previous_duty_dependent_root: %w", err) + previous_duty_dependent_root := cfg.forkChoice.AnchorRoot() + current_duty_dependent_root := cfg.forkChoice.AnchorRoot() + var err error + if headEpoch > 1 { + previous_duty_dependent_root, err = headState.GetBlockRootAtSlot((headEpoch-1)*cfg.beaconCfg.SlotsPerEpoch - 1) + if err != nil { + return fmt.Errorf("failed to get block root at slot for previous_duty_dependent_root: %w", err) + } } - current_duty_dependent_root, err := headState.GetBlockRootAtSlot(headEpoch*cfg.beaconCfg.SlotsPerEpoch - 1) - if err != nil { - return fmt.Errorf("failed to get block root at slot for current_duty_dependent_root: %w", err) + if headEpoch > 0 { + current_duty_dependent_root, err = headState.GetBlockRootAtSlot(headEpoch*cfg.beaconCfg.SlotsPerEpoch - 1) + if err != nil { + return fmt.Errorf("failed to get block root at slot for current_duty_dependent_root: %w", err) + } } stateRoot, err := headState.HashSSZ() @@ -240,6 +247,18 @@ func emitHeadEvent(cfg *Cfg, headSlot uint64, headRoot common.Hash, headState *s CurrentDutyDependentRoot: current_duty_dependent_root, ExecutionOptimistic: false, }) + currentEpochDependentRoot, nextEpochDependentRoot := previous_duty_dependent_root, current_duty_dependent_root + cfg.emitter.State().SendHeadV2(&beaconevents.HeadV2Data{ + Version: headState.Version().String(), + Data: beaconevents.HeadV2Content{ + Slot: headSlot, Block: headRoot, State: stateRoot, + PayloadStatus: beaconevents.PayloadStatusName(cfg.forkChoice.GetHeadPayloadStatus()), + EpochTransition: cfg.beaconCfg.SlotsPerEpoch != 0 && headSlot%cfg.beaconCfg.SlotsPerEpoch == 0, + CurrentEpochDependentRoot: currentEpochDependentRoot, + NextEpochDependentRoot: nextEpochDependentRoot, + ExecutionOptimistic: cfg.forkChoice.IsHeadOptimistic(), + }, + }) return nil } diff --git a/cmd/caplin/caplin1/run.go b/cmd/caplin/caplin1/run.go index ea71009a3ed..4b7aaa31e6a 100644 --- a/cmd/caplin/caplin1/run.go +++ b/cmd/caplin/caplin1/run.go @@ -482,7 +482,7 @@ func RunCaplinService(ctx context.Context, engine execution_client.ExecutionEngi attesterSlashingService := services.NewAttesterSlashingService(forkChoice) executionPayloadService := services.NewExecutionPayloadService(ctx, forkChoice, beaconConfig, emitters) payloadAttestationService := services.NewPayloadAttestationService(ctx, forkChoice, ethClock, networkConfig, emitters) - proposerPreferencesService := services.NewProposerPreferencesService(syncedDataManager, forkChoice, ethClock, beaconConfig, epbsPool) + proposerPreferencesService := services.NewProposerPreferencesService(syncedDataManager, forkChoice, ethClock, beaconConfig, epbsPool, emitters) executionPayloadBidService := services.NewExecutionPayloadBidService(ctx, syncedDataManager, forkChoice, ethClock, beaconConfig, epbsPool, emitters) registry.RegisterGossipServices( gossipManager, From 1dde80ca32f65389d451a7e1cd5306d48e05f49e Mon Sep 17 00:00:00 2001 From: kewei Date: Tue, 25 Aug 2026 20:12:27 +0800 Subject: [PATCH 04/43] cl: align Gloas beacon APIs --- cl/beacon/beaconevents/gloas_events_test.go | 131 +++++++++ cl/beacon/beaconevents/head_v2.go | 82 ++++++ cl/beacon/beaconevents/model.go | 1 - cl/beacon/beaconevents/operation_feed.go | 12 +- cl/beacon/beaconevents/state_feed.go | 2 +- cl/beacon/builder/client.go | 179 ++++++++--- cl/beacon/builder/client_test.go | 132 +++++++++ cl/beacon/handler/block_production.go | 277 ++++++++---------- cl/beacon/handler/block_production_test.go | 125 ++++++-- cl/beacon/handler/block_production_v4.go | 105 +++---- cl/beacon/handler/builder_preferences.go | 89 +++++- cl/beacon/handler/builder_preferences_test.go | 82 ++++++ cl/beacon/handler/epbs.go | 109 ++++--- cl/beacon/handler/epbs_test.go | 49 +++- cl/beacon/handler/epbs_validation_test.go | 39 +++ cl/beacon/handler/events.go | 1 - cl/beacon/handler/events_gloas_test.go | 5 + cl/beacon/handler/handler.go | 13 +- cl/beacon/handler/utils_test.go | 3 + cl/beacon/handler/validator_test.go | 1 + cl/cltypes/beacon_block.go | 4 - .../forkchoice/fork_graph/fork_graph_disk.go | 20 ++ .../forkchoice/fork_graph/fork_graph_test.go | 13 + cl/phase1/forkchoice/fork_graph/interface.go | 1 + cl/phase1/forkchoice/forkchoice.go | 4 + cl/phase1/forkchoice/forkchoice_test.go | 4 + cl/phase1/forkchoice/interface.go | 9 + .../mock_services/forkchoice_mock.go | 70 +++-- cl/phase1/forkchoice/on_block.go | 11 + cl/phase1/forkchoice/on_execution_payload.go | 21 ++ cl/phase1/network/services/block_service.go | 69 +++-- .../services/execution_payload_service.go | 53 ++-- .../execution_payload_service_test.go | 118 ++++++++ .../mock_services/block_service_mock.go | 42 ++- .../services/proposer_preferences_service.go | 3 +- .../proposer_preferences_service_test.go | 41 +++ cl/phase1/network/services/types.go | 7 +- cl/phase1/stages/forkchoice.go | 53 ++-- cmd/caplin/caplin1/option.go | 25 +- cmd/caplin/caplin1/option_test.go | 46 +++ cmd/caplin/caplin1/run.go | 11 +- common/event/feed.go | 31 ++ common/event/feed_test.go | 81 +++++ 43 files changed, 1703 insertions(+), 471 deletions(-) create mode 100644 cl/beacon/beaconevents/head_v2.go create mode 100644 cl/beacon/handler/epbs_validation_test.go create mode 100644 cmd/caplin/caplin1/option_test.go diff --git a/cl/beacon/beaconevents/gloas_events_test.go b/cl/beacon/beaconevents/gloas_events_test.go index 277150400ff..f336cf1cd0f 100644 --- a/cl/beacon/beaconevents/gloas_events_test.go +++ b/cl/beacon/beaconevents/gloas_events_test.go @@ -18,13 +18,144 @@ package beaconevents import ( "testing" + "time" "github.com/stretchr/testify/require" + "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" + "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/common" ) +func TestBuildHeadV2DataUsesGenesisRootInEpochZeroAndOne(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + genesisRoot := common.Hash{1} + epochZeroEndRoot := common.Hash{2} + headRoot := common.Hash{3} + stateRoot := common.Hash{4} + + tests := []struct { + name string + slot uint64 + currentRoot common.Hash + nextRoot common.Hash + }{ + {name: "epoch_zero", slot: 1, currentRoot: genesisRoot, nextRoot: genesisRoot}, + {name: "epoch_one", slot: cfg.SlotsPerEpoch + 1, currentRoot: genesisRoot, nextRoot: epochZeroEndRoot}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + headState := state.New(&cfg) + headState.SetVersion(clparams.GloasVersion) + headState.SetSlot(test.slot) + headState.SetBlockRootAt(0, genesisRoot) + headState.SetBlockRootAt(int(cfg.SlotsPerEpoch-1), epochZeroEndRoot) + + event, err := BuildHeadV2Data(&cfg, headState, test.slot, headRoot, stateRoot, "full", true) + require.NoError(t, err) + require.Equal(t, test.currentRoot, event.Data.CurrentEpochDependentRoot) + require.Equal(t, test.nextRoot, event.Data.NextEpochDependentRoot) + require.Equal(t, stateRoot, event.Data.State) + require.Equal(t, "full", event.Data.PayloadStatus) + require.True(t, event.Data.ExecutionOptimistic) + }) + } +} + +func TestGloasEventFeedsDoNotBlockOnSlowSubscriber(t *testing.T) { + tests := []struct { + name string + subscribe func(*EventEmitter, chan *EventStream) func() + send func(*EventEmitter) int + }{ + { + name: "payload_attestation_message", + subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() { + sub := emitter.Operation().Subscribe(ch) + return sub.Unsubscribe + }, + send: func(emitter *EventEmitter) int { + return emitter.Operation().SendPayloadAttestationMessage(&PayloadAttestationMessageData{}) + }, + }, + { + name: "execution_payload_bid", + subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() { + sub := emitter.Operation().Subscribe(ch) + return sub.Unsubscribe + }, + send: func(emitter *EventEmitter) int { + return emitter.Operation().SendExecutionPayloadBid(&SignedExecutionPayloadBidData{}) + }, + }, + { + name: "execution_payload_available", + subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() { + sub := emitter.Operation().Subscribe(ch) + return sub.Unsubscribe + }, + send: func(emitter *EventEmitter) int { + return emitter.Operation().SendExecutionPayloadAvailable(&ExecutionPayloadAvailableData{}) + }, + }, + { + name: "execution_payload", + subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() { + sub := emitter.Operation().Subscribe(ch) + return sub.Unsubscribe + }, + send: func(emitter *EventEmitter) int { + return emitter.Operation().SendExecutionPayload(&ExecutionPayloadData{}) + }, + }, + { + name: "execution_payload_gossip", + subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() { + sub := emitter.Operation().Subscribe(ch) + return sub.Unsubscribe + }, + send: func(emitter *EventEmitter) int { + return emitter.Operation().SendExecutionPayloadGossip(&ExecutionPayloadGossipData{}) + }, + }, + { + name: "proposer_preferences", + subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() { + sub := emitter.Operation().Subscribe(ch) + return sub.Unsubscribe + }, + send: func(emitter *EventEmitter) int { + return emitter.Operation().SendProposerPreferences(&VersionedSignedProposerPreferences{}) + }, + }, + { + name: "head_v2", + subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() { + sub := emitter.State().Subscribe(ch) + return sub.Unsubscribe + }, + send: func(emitter *EventEmitter) int { return emitter.State().SendHeadV2(&HeadV2Data{}) }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + emitter := NewEventEmitter() + unsubscribe := test.subscribe(emitter, make(chan *EventStream)) + defer unsubscribe() + completed := make(chan int, 1) + go func() { completed <- test.send(emitter) }() + select { + case delivered := <-completed: + require.Zero(t, delivered) + case <-time.After(time.Second): + t.Fatal("Gloas event emission blocked on a slow subscriber") + } + }) + } +} + func TestGloasEventFeeds(t *testing.T) { emitter := NewEventEmitter() stateEvents := make(chan *EventStream, 1) diff --git a/cl/beacon/beaconevents/head_v2.go b/cl/beacon/beaconevents/head_v2.go new file mode 100644 index 00000000000..df4d0d5bcaa --- /dev/null +++ b/cl/beacon/beaconevents/head_v2.go @@ -0,0 +1,82 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package beaconevents + +import ( + "errors" + "fmt" + + "github.com/erigontech/erigon/cl/abstract" + "github.com/erigontech/erigon/cl/clparams" + "github.com/erigontech/erigon/common" +) + +// BuildHeadV2Data derives a head_v2 event from one head-state snapshot. +func BuildHeadV2Data( + beaconCfg *clparams.BeaconChainConfig, + headState abstract.BeaconState, + headSlot uint64, + headRoot common.Hash, + stateRoot common.Hash, + payloadStatus string, + executionOptimistic bool, +) (*HeadV2Data, error) { + if beaconCfg == nil || beaconCfg.SlotsPerEpoch == 0 { + return nil, errors.New("invalid beacon configuration") + } + if headState == nil { + return nil, errors.New("nil head state") + } + + genesisRoot := headRoot + var err error + if headSlot > 0 { + genesisRoot, err = headState.GetBlockRootAtSlot(0) + if err != nil { + return nil, fmt.Errorf("get genesis block root: %w", err) + } + } + headEpoch := headSlot / beaconCfg.SlotsPerEpoch + currentDependentRoot := genesisRoot + nextDependentRoot := genesisRoot + if headEpoch > 1 { + currentDependentRoot, err = headState.GetBlockRootAtSlot((headEpoch-1)*beaconCfg.SlotsPerEpoch - 1) + if err != nil { + return nil, fmt.Errorf("get current epoch dependent root: %w", err) + } + } + if headEpoch > 0 { + nextDependentRoot, err = headState.GetBlockRootAtSlot(headEpoch*beaconCfg.SlotsPerEpoch - 1) + if err != nil { + return nil, fmt.Errorf("get next epoch dependent root: %w", err) + } + } + + return &HeadV2Data{ + Version: headState.Version().String(), + Data: HeadV2Content{ + Slot: headSlot, + Block: headRoot, + State: stateRoot, + PayloadStatus: payloadStatus, + EpochTransition: headSlot%beaconCfg.SlotsPerEpoch == 0, + CurrentEpochDependentRoot: currentDependentRoot, + NextEpochDependentRoot: nextDependentRoot, + ExecutionOptimistic: executionOptimistic, + }, + }, nil +} diff --git a/cl/beacon/beaconevents/model.go b/cl/beacon/beaconevents/model.go index f50b7697e7b..76ffe4b6ea1 100644 --- a/cl/beacon/beaconevents/model.go +++ b/cl/beacon/beaconevents/model.go @@ -84,7 +84,6 @@ const ( StateLightClientFinalityUpdate EventTopic = "light_client_finality_update" StateLightClientOptimisticUpdate EventTopic = "light_client_optimistic_update" StatePayloadAttributes EventTopic = "payload_attributes" - StateFastConfirmation EventTopic = "fast_confirmation" ) // State event data types diff --git a/cl/beacon/beaconevents/operation_feed.go b/cl/beacon/beaconevents/operation_feed.go index a0a7536279d..9ab44de8830 100644 --- a/cl/beacon/beaconevents/operation_feed.go +++ b/cl/beacon/beaconevents/operation_feed.go @@ -81,7 +81,7 @@ func (f *operationFeed) SendDataColumnSidecar(value *DataColumnSidecarData) int // SendPayloadAttestationMessage emits a payload_attestation_message event. [New in Gloas:EIP7732] func (f *operationFeed) SendPayloadAttestationMessage(value *PayloadAttestationMessageData) int { - return f.feed.Send(&EventStream{ + return f.feed.TrySend(&EventStream{ Event: OpPayloadAttestationMessage, Data: value, }) @@ -89,7 +89,7 @@ func (f *operationFeed) SendPayloadAttestationMessage(value *PayloadAttestationM // SendExecutionPayloadBid emits an execution_payload_bid event. [New in Gloas:EIP7732] func (f *operationFeed) SendExecutionPayloadBid(value *SignedExecutionPayloadBidData) int { - return f.feed.Send(&EventStream{ + return f.feed.TrySend(&EventStream{ Event: OpExecutionPayloadBid, Data: value, }) @@ -97,20 +97,20 @@ func (f *operationFeed) SendExecutionPayloadBid(value *SignedExecutionPayloadBid // SendExecutionPayloadAvailable emits an execution_payload_available event. [New in Gloas:EIP7732] func (f *operationFeed) SendExecutionPayloadAvailable(value *ExecutionPayloadAvailableData) int { - return f.feed.Send(&EventStream{ + return f.feed.TrySend(&EventStream{ Event: OpExecutionPayloadAvailable, Data: value, }) } func (f *operationFeed) SendExecutionPayload(value *ExecutionPayloadData) int { - return f.feed.Send(&EventStream{Event: OpExecutionPayload, Data: value}) + return f.feed.TrySend(&EventStream{Event: OpExecutionPayload, Data: value}) } func (f *operationFeed) SendExecutionPayloadGossip(value *ExecutionPayloadGossipData) int { - return f.feed.Send(&EventStream{Event: OpExecutionPayloadGossip, Data: value}) + return f.feed.TrySend(&EventStream{Event: OpExecutionPayloadGossip, Data: value}) } func (f *operationFeed) SendProposerPreferences(value *VersionedSignedProposerPreferences) int { - return f.feed.Send(&EventStream{Event: OpProposerPreferences, Data: value}) + return f.feed.TrySend(&EventStream{Event: OpProposerPreferences, Data: value}) } diff --git a/cl/beacon/beaconevents/state_feed.go b/cl/beacon/beaconevents/state_feed.go index 7366ade1151..f7bee9ec3d4 100644 --- a/cl/beacon/beaconevents/state_feed.go +++ b/cl/beacon/beaconevents/state_feed.go @@ -26,7 +26,7 @@ func (f *stateFeed) SendHead(value *HeadData) int { } func (f *stateFeed) SendHeadV2(value *HeadV2Data) int { - return f.feed.Send(&EventStream{Event: StateHeadV2, Data: value}) + return f.feed.TrySend(&EventStream{Event: StateHeadV2, Data: value}) } // The node has received a block (from P2P or API) that is successfully imported on the fork-choice on_block handler diff --git a/cl/beacon/builder/client.go b/cl/beacon/builder/client.go index 4b607affe40..10a78b058a0 100644 --- a/cl/beacon/builder/client.go +++ b/cl/beacon/builder/client.go @@ -27,8 +27,12 @@ import ( "net/http" "net/url" "strconv" + "strings" + "sync" "time" + "golang.org/x/sync/semaphore" + "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" @@ -41,31 +45,65 @@ var _ BuilderClient = &builderClient{} var ErrNoContent = errors.New("no http content") -const maxBuilderResponseBodySize = 1 << 20 +const ( + maxBuilderResponseBodySize = 1 << 20 + maxBuilderErrorBodySize = 256 + builderPreferencesTimeout = time.Second + defaultBuilderCallLimit = 32 +) + +type BuilderTargetPolicy struct { + AllowPrivate bool +} type builderClient struct { // ref: https://ethereum.github.io/builder-specs/#/ - httpClient *http.Client - url *url.URL - beaconConfig *clparams.BeaconChainConfig - lookupIP func(context.Context, string) ([]net.IPAddr, error) + httpClient *http.Client + url *url.URL + beaconConfig *clparams.BeaconChainConfig + lookupIP func(context.Context, string) ([]net.IPAddr, error) + targetPolicy BuilderTargetPolicy + transport http.RoundTripper + admission *semaphore.Weighted + admissionOnce sync.Once } func NewBlockBuilderClient(baseUrl string, beaconConfig *clparams.BeaconChainConfig) *builderClient { - u, err := url.Parse(baseUrl) - if err != nil { - panic(err) + return newBlockBuilderClient(baseUrl, beaconConfig, BuilderTargetPolicy{}, true) +} + +func NewDynamicBuilderClient(beaconConfig *clparams.BeaconChainConfig, policy BuilderTargetPolicy) *builderClient { + return newBlockBuilderClient("", beaconConfig, policy, false) +} + +func NewBlockBuilderClientWithPolicy(baseURL string, beaconConfig *clparams.BeaconChainConfig, policy BuilderTargetPolicy) *builderClient { + return newBlockBuilderClient(baseURL, beaconConfig, policy, true) +} + +func newBlockBuilderClient(baseUrl string, beaconConfig *clparams.BeaconChainConfig, policy BuilderTargetPolicy, checkStatus bool) *builderClient { + var u *url.URL + var err error + if baseUrl != "" { + u, err = url.Parse(baseUrl) + if err != nil { + panic(err) + } } c := &builderClient{ httpClient: &http.Client{}, url: u, beaconConfig: beaconConfig, + targetPolicy: policy, + transport: newPinnedBuilderTransport(nil), + admission: semaphore.NewWeighted(defaultBuilderCallLimit), + } + if checkStatus { + if err := c.GetStatus(context.Background()); err != nil { + log.Error("cannot connect to builder client", "url", baseUrl, "error", err) + panic("cannot connect to builder client") + } + log.Info("Builder client is ready", "url", baseUrl) } - if err := c.GetStatus(context.Background()); err != nil { - log.Error("cannot connect to builder client", "url", baseUrl, "error", err) - panic("cannot connect to builder client") - } - log.Info("Builder client is ready", "url", baseUrl) return c } @@ -214,11 +252,17 @@ func (b *builderClient) SubmitBuilderPreferences(ctx context.Context, builderURL if err != nil { return err } - targetURL, err := b.builderEndpoint(ctx, builderURL, "eth", "v1", "builder", "builder_preferences", proposerPubkey.Hex()) + requestContext, cancel := context.WithTimeout(ctx, builderPreferencesTimeout) + defer cancel() + if err := b.builderAdmission().Acquire(requestContext, 1); err != nil { + return err + } + defer b.builderAdmission().Release(1) + target, err := b.builderEndpoint(requestContext, b.targetPolicy, builderURL, "eth", "v1", "builder", "builder_preferences", proposerPubkey.Hex()) if err != nil { return err } - response, err := b.builderCall(ctx, http.MethodPost, targetURL, map[string]string{ + response, err := b.builderCall(requestContext, http.MethodPost, target, map[string]string{ "Eth-Consensus-Version": clparams.GloasVersion.String(), }, bytes.NewReader(payload)) if err != nil { @@ -240,15 +284,19 @@ func (b *builderClient) RequestExecutionPayloadBid(ctx context.Context, builderU } requestContext, cancel := context.WithTimeout(ctx, timeout) defer cancel() + if err := b.builderAdmission().Acquire(requestContext, 1); err != nil { + return nil, err + } + defer b.builderAdmission().Release(1) payload, err := json.Marshal(auth) if err != nil { return nil, err } - targetURL, err := b.builderEndpoint(requestContext, builderURL, "eth", "v1", "builder", "execution_payload_bid", strconv.FormatUint(slot, 10), parentHash.Hex(), parentRoot.Hex(), proposerPubkey.Hex()) + target, err := b.builderEndpoint(requestContext, b.targetPolicy, builderURL, "eth", "v1", "builder", "execution_payload_bid", strconv.FormatUint(slot, 10), parentHash.Hex(), parentRoot.Hex(), proposerPubkey.Hex()) if err != nil { return nil, err } - response, err := b.builderCall(requestContext, http.MethodPost, targetURL, map[string]string{ + response, err := b.builderCall(requestContext, http.MethodPost, target, map[string]string{ "Eth-Consensus-Version": clparams.GloasVersion.String(), "Date-Milliseconds": strconv.FormatInt(time.Now().UnixMilli(), 10), "X-Timeout-Ms": strconv.FormatInt(timeoutMilliseconds, 10), @@ -286,11 +334,15 @@ func (b *builderClient) SubmitSignedBeaconBlock(ctx context.Context, builderURL if err != nil { return err } - targetURL, err := b.builderEndpoint(ctx, builderURL, "eth", "v1", "builder", "beacon_blocks") + if err := b.builderAdmission().Acquire(ctx, 1); err != nil { + return err + } + defer b.builderAdmission().Release(1) + target, err := b.builderEndpoint(ctx, BuilderTargetPolicy{}, builderURL, "eth", "v1", "builder", "beacon_blocks") if err != nil { return err } - response, err := b.builderCall(ctx, http.MethodPost, targetURL, map[string]string{ + response, err := b.builderCall(ctx, http.MethodPost, target, map[string]string{ "Eth-Consensus-Version": block.Version().String(), }, bytes.NewReader(payload)) if err != nil { @@ -302,18 +354,21 @@ func (b *builderClient) SubmitSignedBeaconBlock(ctx context.Context, builderURL return nil } -func (b *builderClient) builderEndpoint(ctx context.Context, rawURL string, path ...string) (string, error) { +type builderTarget struct { + url string + hostname string + ip net.IP +} + +func (b *builderClient) builderEndpoint(ctx context.Context, policy BuilderTargetPolicy, rawURL string, path ...string) (builderTarget, error) { target, err := url.Parse(rawURL) if err != nil { - return "", err + return builderTarget{}, err } if (target.Scheme != "http" && target.Scheme != "https") || target.Host == "" || target.User != nil { - return "", errors.New("builder URL must be an HTTP(S) URL without user information") + return builderTarget{}, errors.New("builder URL must be an HTTP(S) URL without user information") } hostname := target.Hostname() - if hostname == "localhost" { - return "", errors.New("builder URL resolves to a non-public address") - } addresses := []net.IPAddr(nil) if ip := net.ParseIP(hostname); ip != nil { addresses = []net.IPAddr{{IP: ip}} @@ -324,32 +379,40 @@ func (b *builderClient) builderEndpoint(ctx context.Context, rawURL string, path } addresses, err = lookup(ctx, hostname) if err != nil { - return "", fmt.Errorf("resolve builder URL: %w", err) + return builderTarget{}, fmt.Errorf("resolve builder URL: %w", err) } } if len(addresses) == 0 { - return "", errors.New("builder URL has no resolved addresses") + return builderTarget{}, errors.New("builder URL has no resolved addresses") } for _, address := range addresses { - if !isPublicBuilderIP(address.IP) { - return "", fmt.Errorf("builder URL resolves to non-public address %s", address.IP) + if !isAllowedBuilderIP(address.IP, policy) { + return builderTarget{}, fmt.Errorf("builder URL resolves to disallowed address %s", address.IP) } } - return target.JoinPath(path...).String(), nil + return builderTarget{url: target.JoinPath(path...).String(), hostname: hostname, ip: addresses[0].IP}, nil } func isPublicBuilderIP(ip net.IP) bool { return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsUnspecified() } +func isAllowedBuilderIP(ip net.IP, policy BuilderTargetPolicy) bool { + if isPublicBuilderIP(ip) { + return true + } + return policy.AllowPrivate && ip != nil && (ip.IsPrivate() || ip.IsLoopback()) +} + type builderHTTPResponse struct { body []byte header http.Header status int } -func (b *builderClient) builderCall(ctx context.Context, method, targetURL string, headers map[string]string, body io.Reader) (*builderHTTPResponse, error) { - request, err := http.NewRequestWithContext(ctx, method, targetURL, body) +func (b *builderClient) builderCall(ctx context.Context, method string, target builderTarget, headers map[string]string, body io.Reader) (*builderHTTPResponse, error) { + requestContext := context.WithValue(ctx, pinnedBuilderTargetKey{}, pinnedBuilderTarget{hostname: target.hostname, ip: target.ip}) + request, err := http.NewRequestWithContext(requestContext, method, target.url, body) if err != nil { return nil, err } @@ -362,6 +425,9 @@ func (b *builderClient) builderCall(ctx context.Context, method, targetURL strin return nil, errors.New("nil builder HTTP client") } client := *b.httpClient + if b.transport != nil { + client.Transport = b.transport + } client.CheckRedirect = func(*http.Request, []*http.Request) error { return errors.New("builder redirects are not allowed") } @@ -370,19 +436,62 @@ func (b *builderClient) builderCall(ctx context.Context, method, targetURL strin return nil, err } defer response.Body.Close() - bodyBytes, err := io.ReadAll(io.LimitReader(response.Body, maxBuilderResponseBodySize+1)) + bodyLimit := int64(maxBuilderResponseBodySize) + if response.StatusCode < 200 || response.StatusCode >= 300 { + bodyLimit = maxBuilderErrorBodySize + } + bodyBytes, err := io.ReadAll(io.LimitReader(response.Body, bodyLimit+1)) if err != nil { return nil, err } - if len(bodyBytes) > maxBuilderResponseBodySize { + if len(bodyBytes) > maxBuilderResponseBodySize && bodyLimit == maxBuilderResponseBodySize { return nil, fmt.Errorf("builder response exceeds %d bytes", maxBuilderResponseBodySize) } if response.StatusCode < 200 || response.StatusCode >= 300 { - return nil, fmt.Errorf("builder returned status code %d: %s", response.StatusCode, string(bodyBytes)) + return nil, fmt.Errorf("builder returned status code %d", response.StatusCode) } return &builderHTTPResponse{body: bodyBytes, header: response.Header.Clone(), status: response.StatusCode}, nil } +func (b *builderClient) builderAdmission() *semaphore.Weighted { + b.admissionOnce.Do(func() { + if b.admission == nil { + b.admission = semaphore.NewWeighted(defaultBuilderCallLimit) + } + }) + return b.admission +} + +type pinnedBuilderTargetKey struct{} + +type pinnedBuilderTarget struct { + hostname string + ip net.IP +} + +func newPinnedBuilderTransport(dialContext func(context.Context, string, string) (net.Conn, error)) http.RoundTripper { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + if dialContext == nil { + dialContext = (&net.Dialer{}).DialContext + } + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + pinned, ok := ctx.Value(pinnedBuilderTargetKey{}).(pinnedBuilderTarget) + if !ok { + return nil, errors.New("builder target was not resolved before dialing") + } + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + if !strings.EqualFold(host, pinned.hostname) { + return nil, errors.New("builder dial target does not match resolved host") + } + return dialContext(ctx, network, net.JoinHostPort(pinned.ip.String(), port)) + } + return transport +} + func httpCall[T any](ctx context.Context, client *http.Client, method, rawURL string, headers map[string]string, payloadReader io.Reader, body T) (*T, error) { request, err := http.NewRequestWithContext(ctx, method, rawURL, payloadReader) if err != nil { diff --git a/cl/beacon/builder/client_test.go b/cl/beacon/builder/client_test.go index 4d2a79bd3c6..41ad284a8c5 100644 --- a/cl/beacon/builder/client_test.go +++ b/cl/beacon/builder/client_test.go @@ -17,6 +17,7 @@ package builder import ( + "bufio" "bytes" "context" _ "embed" @@ -29,11 +30,13 @@ import ( "net/url" "strconv" "strings" + "sync" "sync/atomic" "testing" "time" "github.com/stretchr/testify/require" + "golang.org/x/sync/semaphore" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" @@ -446,6 +449,135 @@ func TestSubmitBuilderPreferences(t *testing.T) { require.NoError(t, client.SubmitBuilderPreferences(context.Background(), "https://builder.example", proposer, request)) } +func TestSubmitBuilderPreferencesHasBoundedTimeoutAndError(t *testing.T) { + request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()} + t.Run("timeout", func(t *testing.T) { + client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { + <-r.Context().Done() + return nil, r.Context().Err() + })) + started := time.Now() + err := client.SubmitBuilderPreferences(context.Background(), "https://builder.example", common.Bytes48{}, request) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Less(t, time.Since(started), 2*time.Second) + }) + + t.Run("error body", func(t *testing.T) { + client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { + return builderTestResponse(r, http.StatusBadRequest, strings.Repeat("x", 1<<20), nil), nil + })) + err := client.SubmitBuilderPreferences(context.Background(), "https://builder.example", common.Bytes48{}, request) + require.Error(t, err) + require.Less(t, len(err.Error()), 1024) + require.NotContains(t, err.Error(), "xxxx") + }) +} + +func TestDynamicBuilderCallsShareBoundedAdmission(t *testing.T) { + var active atomic.Int64 + var maximum atomic.Int64 + release := make(chan struct{}) + client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { + current := active.Add(1) + defer active.Add(-1) + for current > maximum.Load() && !maximum.CompareAndSwap(maximum.Load(), current) { + } + select { + case <-release: + case <-r.Context().Done(): + return nil, r.Context().Err() + } + return builderTestResponse(r, http.StatusAccepted, "", nil), nil + })) + request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()} + + var wg sync.WaitGroup + for range defaultBuilderCallLimit + 8 { + wg.Go(func() { + _ = client.SubmitBuilderPreferences(t.Context(), "https://builder.example", common.Bytes48{}, request) + }) + } + require.Eventually(t, func() bool { return maximum.Load() == defaultBuilderCallLimit }, time.Second, time.Millisecond) + close(release) + wg.Wait() + require.EqualValues(t, defaultBuilderCallLimit, maximum.Load()) +} + +func TestDynamicBuilderAdmissionCancellationDoesNotLeakPermit(t *testing.T) { + started := make(chan struct{}, 1) + release := make(chan struct{}) + client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { + started <- struct{}{} + select { + case <-release: + return builderTestResponse(r, http.StatusAccepted, "", nil), nil + case <-r.Context().Done(): + return nil, r.Context().Err() + } + })) + client.admission = semaphore.NewWeighted(1) + request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()} + firstDone := make(chan error, 1) + go func() { + firstDone <- client.SubmitBuilderPreferences(t.Context(), "https://builder.example", common.Bytes48{}, request) + }() + <-started + waitingContext, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond) + defer cancel() + require.ErrorIs(t, client.SubmitBuilderPreferences(waitingContext, "https://builder.example", common.Bytes48{}, request), context.DeadlineExceeded) + close(release) + require.NoError(t, <-firstDone) + require.NoError(t, client.SubmitBuilderPreferences(t.Context(), "https://builder.example", common.Bytes48{}, request)) +} + +func TestDynamicBuilderClientDoesNotRequireLegacyRelay(t *testing.T) { + client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{}) + require.NotNil(t, client) + require.Nil(t, client.url) +} + +func TestDynamicBuilderDialUsesValidatedAddress(t *testing.T) { + var dialed string + client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{}) + client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil + } + client.transport = newPinnedBuilderTransport(func(_ context.Context, _, address string) (net.Conn, error) { + dialed = address + clientConn, serverConn := net.Pipe() + go func() { + defer serverConn.Close() + request, err := http.ReadRequest(bufio.NewReader(serverConn)) + if err == nil { + request.Body.Close() + _, _ = serverConn.Write([]byte("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) + } + }() + return clientConn, nil + }) + request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()} + require.NoError(t, client.SubmitBuilderPreferences(t.Context(), "http://builder.example:18550", common.Bytes48{}, request)) + require.Equal(t, "93.184.216.34:18550", dialed) +} + +func TestPrivateBuilderTargetsRequireExplicitPolicy(t *testing.T) { + request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()} + rejected := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{}) + rejected.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil + } + require.Error(t, rejected.SubmitBuilderPreferences(t.Context(), "http://builder.local:18550", common.Bytes48{}, request)) + + allowed := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{AllowPrivate: true}) + allowed.lookupIP = rejected.lookupIP + allowed.httpClient.Transport = mockRoundTripper(func(r *http.Request) (*http.Response, error) { + return builderTestResponse(r, http.StatusAccepted, "", nil), nil + }) + allowed.transport = nil + require.NoError(t, allowed.SubmitBuilderPreferences(t.Context(), "http://builder.local:18550", common.Bytes48{}, request)) + require.Error(t, allowed.SubmitSignedBeaconBlock(t.Context(), "http://builder.local:18550", cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion))) +} + func TestSubmitSignedBeaconBlock(t *testing.T) { block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 7401aca8cbf..360997f373b 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -52,6 +52,7 @@ import ( "github.com/erigontech/erigon/cl/phase1/forkchoice" "github.com/erigontech/erigon/cl/phase1/network/subnets" "github.com/erigontech/erigon/cl/pool" + ssz2 "github.com/erigontech/erigon/cl/ssz" "github.com/erigontech/erigon/cl/transition" "github.com/erigontech/erigon/cl/transition/impl/eth2" "github.com/erigontech/erigon/cl/transition/machine" @@ -79,6 +80,7 @@ const ( ) var errBuilderNotEnabled = errors.New("builder is not enabled") +var errPublishedBlockValidation = errors.New("published block validation failed") const ( caplinClientCode = "CN" @@ -755,7 +757,7 @@ func (a *ApiHandler) GetEthV3ValidatorBlock( } // Cache envelope by slot so the VC can retrieve it via // GET /eth/v1/validator/execution_payload_envelope/{slot}/{builder_index} - a.selfBuildEnvelopes.Add(targetSlot, envelope) + a.selfBuildEnvelopes.Add(selfBuildEnvelopeKey{Slot: targetSlot, BeaconBlockRoot: beaconBlockRoot}, envelope) // SSZ encoding only serializes Data, not Extra — only include // the envelope in JSON responses to keep the header truthful. options := gloasBlockOptionsFromContext(ctx) @@ -932,18 +934,6 @@ func (a *ApiHandler) produceBlock( candidates = append(candidates, a.requestConfiguredBuilderBids(ctx, baseState, targetSlot, proposerPubkey, selfBid, options.builderConfig.Builders)...) } } - if options != nil && options.suppliedBid != nil && options.suppliedBid.Message != nil && - options.suppliedBid.Message.ParentBlockHash == selfBid.ParentBlockHash && - options.suppliedBid.Message.ParentBlockRoot == selfBid.ParentBlockRoot { - validator, ok := a.executionPayloadBidService.(executionPayloadBidValidator) - if ok && validator.ValidateBid(ctx, options.suppliedBid) == nil { - candidates = append(candidates, gloasBidCandidate{ - bid: options.suppliedBid, - boostFactor: options.builderConfig.BuilderBoostFactor, - maxExecutionPayment: math.MaxUint64, - }) - } - } selected := selectGloasBid(localExecValue, candidates) if selected != nil { log.Info("GLOAS: selected external builder bid over self-build", @@ -1022,10 +1012,10 @@ type executionPayloadBidValidator interface { } func selectGloasBid(localExecutionValueWei *big.Int, candidates []gloasBidCandidate) *gloasBidCandidate { - if localExecutionValueWei == nil { - localExecutionValueWei = new(big.Int) + var localWeighted *big.Int + if localExecutionValueWei != nil { + localWeighted = new(big.Int).Mul(new(big.Int).Set(localExecutionValueWei), big.NewInt(100)) } - localWeighted := new(big.Int).Mul(new(big.Int).Set(localExecutionValueWei), big.NewInt(100)) var best *gloasBidCandidate bestWeighted := new(big.Int) for i := range candidates { @@ -1053,7 +1043,7 @@ func selectGloasBid(localExecutionValueWei *big.Int, candidates []gloasBidCandid bestWeighted.Set(weighted) } } - if best == nil || localWeighted.Cmp(bestWeighted) >= 0 { + if best == nil || localWeighted != nil && localWeighted.Cmp(bestWeighted) >= 0 { return nil } return best @@ -1781,15 +1771,19 @@ func (a *ApiHandler) postBeaconBlocks(w http.ResponseWriter, r *http.Request, ap if err != nil { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) } - validation := a.parseBlockPublishingValidation(w, r, apiVersion) + validation, err := a.parseBlockPublishingValidation(r, apiVersion) + if err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } // Decode the block block, err := a.parseRequestBeaconBlock(version, r) if err != nil { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) } - _ = validation - - if err := a.broadcastBlock(ctx, block.SignedBlock, block.SignedExecutionPayloadEnvelope); err != nil { + if err := a.broadcastBlock(ctx, block.SignedBlock, validation); err != nil { + if errors.Is(err, errPublishedBlockValidation) { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) + } return nil, beaconhttp.NewEndpointError(http.StatusInternalServerError, err) } return newBeaconResponse(nil), nil @@ -1982,7 +1976,7 @@ func (a *ApiHandler) publishBlindedBlocks(w http.ResponseWriter, r *http.Request } // broadcast the block - if err := a.broadcastBlock(r.Context(), signedBlock); err != nil { + if err := a.broadcastBlock(r.Context(), signedBlock, BlockPublishingValidationGossip); err != nil { return nil, beaconhttp.NewEndpointError(http.StatusInternalServerError, err) } @@ -2004,17 +1998,21 @@ func (a *ApiHandler) parseEthConsensusVersion( return clparams.StringToClVersion(str) } -func (a *ApiHandler) parseBlockPublishingValidation( - w http.ResponseWriter, - r *http.Request, - apiVersion int, -) BlockPublishingValidation { +func (a *ApiHandler) parseBlockPublishingValidation(r *http.Request, apiVersion int) (BlockPublishingValidation, error) { str := r.URL.Query().Get("broadcast_validation") - if apiVersion == 1 || str == string(BlockPublishingValidationGossip) { - return BlockPublishingValidationGossip + if apiVersion == 1 { + return BlockPublishingValidationGossip, nil + } + switch BlockPublishingValidation(str) { + case "", BlockPublishingValidationGossip: + return BlockPublishingValidationGossip, nil + case BlockPublishingValidationConsensus: + return BlockPublishingValidationConsensus, nil + case BlockPublishingValidationConsensusAndEquivocation: + return BlockPublishingValidationConsensusAndEquivocation, nil + default: + return "", fmt.Errorf("invalid broadcast_validation %q", str) } - // fall to consensus anyway. equivocation is not supported yet. - return BlockPublishingValidationConsensus } func (a *ApiHandler) parseRequestBeaconBlock( @@ -2054,6 +2052,8 @@ func (a *ApiHandler) parseRequestBeaconBlock( // parseGloasRequestBeaconBlock handles GLOAS block publishing where the VC sends // a SignedBeaconBlock (SSZ) or a JSON object with signed_block + optional envelope. +const maxGloasPublishedBlockSize = 10 << 20 + func (a *ApiHandler) parseGloasRequestBeaconBlock( version clparams.StateVersion, r *http.Request, @@ -2062,44 +2062,28 @@ func (a *ApiHandler) parseGloasRequestBeaconBlock( switch r.Header.Get("Content-Type") { case "application/json": - body, err := io.ReadAll(r.Body) + body, err := readBoundedBody(r.Body, maxGloasPublishedBlockSize) if err != nil { return nil, err } - - // Peek at top-level JSON keys to determine the schema. - // DenebSignedBeaconBlock uses "signed_block"; plain SignedBeaconBlock uses "message". - // encoding/json silently ignores unknown keys, so a bare SignedBeaconBlock would - // "successfully" unmarshal into DenebSignedBeaconBlock with all-default fields. - // We must check for the "signed_block" key explicitly to avoid broadcasting an - // empty block. - var probe map[string]json.RawMessage - if err := json.Unmarshal(body, &probe); err != nil { - return nil, fmt.Errorf("json probe: %w", err) - } - if _, hasSignedBlock := probe["signed_block"]; hasSignedBlock { - block := cltypes.NewDenebSignedBeaconBlock(a.beaconChainCfg, version) - if block != nil { - if err := json.Unmarshal(body, block); err == nil { - return block, nil - } - } - } - // Fall back to plain SignedBeaconBlock (keys: "message", "signature") - if err := json.Unmarshal(body, signedBlock); err != nil { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(signedBlock); err != nil { return nil, fmt.Errorf("json: %w", err) } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return nil, errors.New("request body contains trailing data") + } return &cltypes.DenebSignedBeaconBlock{ SignedBlock: signedBlock, }, nil case "application/octet-stream": - octect, err := io.ReadAll(r.Body) + octect, err := readBoundedBody(r.Body, maxGloasPublishedBlockSize) if err != nil { return nil, err } - // In GLOAS, SSZ payload is just SignedBeaconBlock (no KZGProofs/Blobs wrapper) - if err := signedBlock.DecodeSSZ(octect, int(version)); err != nil { + if err := ssz2.UnmarshalSSZStrict(octect, int(version), signedBlock.Block, signedBlock.Signature[:]); err != nil { return nil, fmt.Errorf("ssz(%w)", err) } return &cltypes.DenebSignedBeaconBlock{ @@ -2109,7 +2093,26 @@ func (a *ApiHandler) parseGloasRequestBeaconBlock( return nil, errors.New("invalid content type") } -func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeaconBlock, signedEnvelope ...*cltypes.SignedExecutionPayloadEnvelope) error { +func readBoundedBody(body io.Reader, limit int64) ([]byte, error) { + b, err := io.ReadAll(io.LimitReader(body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > limit { + return nil, fmt.Errorf("request body exceeds %d bytes", limit) + } + return b, nil +} + +func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeaconBlock, validation BlockPublishingValidation) error { + if validation == BlockPublishingValidationGossip { + if a.blockService == nil { + return errors.New("block gossip validator unavailable") + } + if err := a.blockService.ValidateGossip(ctx, blk); err != nil { + return fmt.Errorf("%w: %w", errPublishedBlockValidation, err) + } + } blkSSZ, err := blk.EncodeSSZ(nil) if err != nil { return err @@ -2209,11 +2212,20 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac } } - go func() { - if err := a.storeBlockAndBlobs(context.Background(), blk, blobsSidecars, columnsSidecars); err != nil { - log.Error("BlockPublishing: Failed to store block and blobs", "err", err) + store := func(ctx context.Context) error { + return a.storeBlockAndBlobs(ctx, blk, blobsSidecars, columnsSidecars, validation == BlockPublishingValidationConsensusAndEquivocation) + } + if validation != BlockPublishingValidationGossip { + if err := store(ctx); err != nil { + return err } - }() + } else { + go func() { + if err := retryPublishedBlockStore(context.Background(), 3, 100*time.Millisecond, store); err != nil { + log.Error("BlockPublishing: Failed to store block and blobs", "err", err) + } + }() + } lenBlobs := 0 if blk.Version() >= clparams.DenebVersion { @@ -2261,11 +2273,7 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac // If the validator client provided a signed envelope, use it directly (real BLS signature). // Otherwise fall back to constructing one from the cache (legacy/fallback path). if blk.Version() >= clparams.GloasVersion { - var validatorSignedEnvelope *cltypes.SignedExecutionPayloadEnvelope - if len(signedEnvelope) > 0 && signedEnvelope[0] != nil { - validatorSignedEnvelope = signedEnvelope[0] - } - if err := a.broadcastSelfBuildEnvelope(ctx, blk, validatorSignedEnvelope); err != nil { + if err := a.broadcastSelfBuildEnvelope(ctx, blk); err != nil { a.logger.Error("Failed to broadcast self-build execution payload envelope", "err", err) } } @@ -2273,6 +2281,26 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac return nil } +func retryPublishedBlockStore(ctx context.Context, attempts int, delay time.Duration, store func(context.Context) error) error { + var err error + for attempt := range attempts { + if err = store(ctx); err == nil { + return nil + } + if attempt+1 == attempts { + break + } + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } + return err +} + func collectPublishedPayloadData( commitments *solid.ListSSZ[*cltypes.KZGCommitment], isGloas bool, @@ -2307,18 +2335,8 @@ func collectPublishedPayloadData( return cellsAndProofs, false, nil } -// broadcastSelfBuildEnvelope constructs and broadcasts a SignedExecutionPayloadEnvelope -// for a self-built GLOAS block. If the validator client provided a signed envelope -// (via the block publish request), it is used directly with the real BLS signature. -// Otherwise, the envelope is reconstructed from the cache as a fallback. -// -// The function: -// 1. Broadcasts the envelope on the execution_payload gossip topic -// 2. Processes the envelope through forkchoice (OnExecutionPayload) so the local -// node transitions the block from PENDING to FULL status -// -// [New in Gloas:EIP7732] -func (a *ApiHandler) broadcastSelfBuildEnvelope(ctx context.Context, blk *cltypes.SignedBeaconBlock, validatorSignedEnvelope *cltypes.SignedExecutionPayloadEnvelope) error { +// broadcastSelfBuildEnvelope applies the locally cached self-build payload without gossiping its placeholder signature. +func (a *ApiHandler) broadcastSelfBuildEnvelope(ctx context.Context, blk *cltypes.SignedBeaconBlock) error { bid := blk.Block.Body.GetSignedExecutionPayloadBid() if bid == nil || bid.Message == nil { return nil // no bid in block, nothing to do @@ -2327,85 +2345,35 @@ func (a *ApiHandler) broadcastSelfBuildEnvelope(ctx context.Context, blk *cltype return nil // not a self-build block; builder will broadcast the envelope } - // Compute the beacon block root blockRoot, err := blk.Block.HashSSZ() if err != nil { return fmt.Errorf("failed to compute block root: %w", err) } - var signedEnvelope *cltypes.SignedExecutionPayloadEnvelope - - if validatorSignedEnvelope != nil && validatorSignedEnvelope.Message != nil { - // Use the validator-signed envelope directly (real BLS signature). - signedEnvelope = validatorSignedEnvelope - log.Debug("BlockPublishing: using validator-signed execution payload envelope", - "slot", blk.Block.Slot, "blockRoot", blockRoot) - } else { - // Fallback: reconstruct from cache. This path uses InfiniteSignature and will - // fail BLS verification on other nodes — it exists only as a backward-compat - // safety net during the transition period. - cached, ok := a.selfBuildPayloads.Get(bid.Message.BlockHash) - if !ok { - return fmt.Errorf("self-build payload not found in cache for block hash %v", bid.Message.BlockHash) - } - - log.Debug("BlockPublishing: no validator-signed envelope provided, falling back to InfiniteSignature (will fail BLS verification on peers)", - "slot", blk.Block.Slot, "blockRoot", blockRoot, "blockHash", bid.Message.BlockHash) - - execReqs := cached.ExecutionRequests - if execReqs == nil { - execReqs = cltypes.NewExecutionRequestsWithVersion(a.beaconChainCfg, clparams.GloasVersion) - } - envelope := &cltypes.ExecutionPayloadEnvelope{ + cached, ok := a.selfBuildPayloads.Get(bid.Message.BlockHash) + if !ok { + return fmt.Errorf("self-build payload not found in cache for block hash %v", bid.Message.BlockHash) + } + execReqs := cached.ExecutionRequests + if execReqs == nil { + execReqs = cltypes.NewExecutionRequestsWithVersion(a.beaconChainCfg, clparams.GloasVersion) + } + signedEnvelope := &cltypes.SignedExecutionPayloadEnvelope{ + Message: &cltypes.ExecutionPayloadEnvelope{ Payload: cached.Payload, ExecutionRequests: execReqs, BuilderIndex: clparams.BuilderIndexSelfBuild, BeaconBlockRoot: blockRoot, ParentBeaconBlockRoot: blk.Block.ParentRoot, - } - signedEnvelope = &cltypes.SignedExecutionPayloadEnvelope{ - Message: envelope, - Signature: common.Bytes96(bls.InfiniteSignature), - } + }, + Signature: common.Bytes96(bls.InfiniteSignature), } - // Remove from cache after use (regardless of path taken) a.selfBuildPayloads.Remove(bid.Message.BlockHash) - - // Process through forkchoice so the local node marks the block as FULL. - // Use ApplyLocalSelfBuildEnvelope instead of OnExecutionPayload: it skips BLS - // signature verification (we produced this envelope locally and may not have the - // VC's private key) while still validating the payload with the EL via NewPayload. - // Note: this typically returns an error because OnBlock (running in a background - // goroutine) has not finished yet — the forkchoice store queues the envelope in - // pendingEnvelopes and OnBlock will pick it up. Debug-level to avoid noisy logs. if err := a.forkchoiceStore.ApplyLocalSelfBuildEnvelope(ctx, signedEnvelope); err != nil { a.logger.Debug("Self-build envelope queued for pending processing", "err", err, "blockRoot", blockRoot) } - // Only broadcast the envelope if it has a real BLS signature. - // Envelopes with InfiniteSignature (fallback when the VC doesn't provide a - // pre-signed envelope) will fail BLS verification on peers, causing them to - // penalize and ban us. Process locally only until the VC supports envelope signing. - if signedEnvelope.Signature == common.Bytes96(bls.InfiniteSignature) { - log.Debug("BlockPublishing: skipping gossip of self-build envelope with InfiniteSignature (no valid BLS signature)", - "slot", blk.Block.Slot, "blockRoot", blockRoot, "blockHash", bid.Message.BlockHash) - } else { - // Broadcast the envelope on the execution_payload gossip topic - encodedSSZ, err := signedEnvelope.EncodeSSZ(nil) - if err != nil { - return fmt.Errorf("failed to encode self-build envelope: %w", err) - } - if err := a.gossipManager.Publish(ctx, gossip.TopicNameExecutionPayload, encodedSSZ); err != nil { - a.logger.Error("Failed to publish self-build execution payload envelope", "err", err, "blockRoot", blockRoot) - } else { - log.Debug("BlockPublishing: broadcast self-build execution payload envelope", - "slot", blk.Block.Slot, - "blockRoot", blockRoot, - "blockHash", bid.Message.BlockHash) - } - } - return nil } @@ -2414,6 +2382,7 @@ func (a *ApiHandler) storeBlockAndBlobs( block *cltypes.SignedBeaconBlock, sidecars []*cltypes.BlobSidecar, columnSidecars []*cltypes.DataColumnSidecar, + rejectEquivocation bool, ) error { finishProduction := a.payloadPreparationGate.beginProduction() defer finishProduction() @@ -2431,6 +2400,17 @@ func (a *ApiHandler) storeBlockAndBlobs( return err } } + currentSlot := a.ethClock.GetCurrentSlot() + a.forkchoiceStore.OnTick(a.ethClock.GenesisTime() + currentSlot*a.beaconChainCfg.SecondsPerSlot) + var blockErr error + if rejectEquivocation { + blockErr = a.forkchoiceStore.OnBlockWithEquivocationCheck(ctx, block, true, true, false) + } else { + blockErr = a.forkchoiceStore.OnBlock(ctx, block, true, true, false) + } + if blockErr != nil { + return fmt.Errorf("%w: %w", errPublishedBlockValidation, blockErr) + } // Cache the execution payload body before writing to DB so the beacon API // can return transactions/withdrawals immediately (before the EL commits). @@ -2445,23 +2425,6 @@ func (a *ApiHandler) storeBlockAndBlobs( return err } - // Advance fork choice time to the current slot so OnBlock accepts the block. - // Normally OnTick is called from the ForkChoice stage, but storeBlockAndBlobs - // runs from the beacon API handler which may execute before the stage loop. - currentSlot := a.ethClock.GetCurrentSlot() - a.forkchoiceStore.OnTick(a.ethClock.GenesisTime() + currentSlot*a.beaconChainCfg.SecondsPerSlot) - - // Skip BLS re-verification for locally-produced blocks. The block was just - // built by this node, so re-verifying the signature is redundant. Additionally, - // AddChainSegment replays from the nearest checkpoint state, and the replayed - // state can produce a different proposer shuffling than the head state used - // during block production (especially on minimal preset with rapid epoch - // boundaries), causing VerifyBlockSignature to fail. - // TODO: fix the root cause in state replay so fullValidation can be re-enabled. - log.Warn("Skipping full validation for locally-produced block", "slot", block.Block.Slot, "proposer", block.Block.ProposerIndex) - if err := a.forkchoiceStore.OnBlock(ctx, block, true, false, false); err != nil { - return err - } headRoot, headSlot, headState, err := a.selectedHeadState(blockRoot) if err != nil { return err diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index 2da47f32ec2..f42e97da1ed 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -45,6 +45,7 @@ import ( "github.com/erigontech/erigon/cl/phase1/core/state/lru" "github.com/erigontech/erigon/cl/phase1/execution_client" "github.com/erigontech/erigon/cl/phase1/forkchoice" + network_services_mock "github.com/erigontech/erigon/cl/phase1/network/services/mock_services" sync_pool_mock "github.com/erigontech/erigon/cl/validator/sync_contribution_pool/mock_services" "github.com/erigontech/erigon/cl/validator/validator_params" "github.com/erigontech/erigon/common" @@ -83,6 +84,20 @@ func TestStoreDataColumnSidecarsRejectsInvalidInput(t *testing.T) { )) } +func TestRetryPublishedBlockStoreRecoversFromTransientFailure(t *testing.T) { + calls := 0 + err := retryPublishedBlockStore(t.Context(), 3, time.Millisecond, func(context.Context) error { + calls++ + if calls == 1 { + return errors.New("transient storage failure") + } + return nil + }) + + require.NoError(t, err) + require.Equal(t, 2, calls) +} + func TestBlockBuilderWindowPreGloas(t *testing.T) { cfg := &clparams.BeaconChainConfig{ SecondsPerSlot: 12, @@ -174,6 +189,16 @@ func TestSelectGloasBidLocalWinsTieAndZeroBoost(t *testing.T) { }})) } +func TestSelectGloasBidUsesValidBuilderWhenLocalBuildIsUnavailable(t *testing.T) { + bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 0)} + selected := selectGloasBid(nil, []gloasBidCandidate{{ + bid: bid, boostFactor: 100, maxExecutionPayment: math.MaxUint64, + }}) + + require.NotNil(t, selected) + require.Same(t, bid, selected.bid) +} + func TestSelectGloasBidCapsExecutionPaymentAndAvoidsOverflow(t *testing.T) { capped := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 1)} capped.Message.ExecutionPayment = 100 @@ -242,6 +267,18 @@ func TestDecodeGloasBlockProductionOptionsJSONAndSSZ(t *testing.T) { } } +func TestDecodeGloasBlockProductionOptionsRejectsInvalidBuildersPerEntry(t *testing.T) { + body := `{"min_bid":"0","builder_boost_factor":"100","builders":[null,{"url":"https://missing-auth.example"},{"url":"https://wrong-slot.example","auth":{"message":{"data":"0x01","slot":"11"},"signature":"0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"},"builder_pubkeys":[],"max_execution_payment":"0","min_bid":"0","builder_boost_factor":"0"},{"url":"https://builder.example","auth":{"message":{"data":"0x01","slot":"10"},"signature":"0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"},"builder_pubkeys":[],"max_execution_payment":"0","min_bid":"0","builder_boost_factor":"0"}]}` + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10?include_payload=true", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Eth-Consensus-Version", "gloas") + + options, err := decodeGloasBlockProductionOptions(httptest.NewRecorder(), req, 10) + require.NoError(t, err) + require.Len(t, options.builderConfig.Builders, 1) + require.Equal(t, "https://builder.example", options.builderConfig.Builders[0].URL) +} + func TestDecodeGloasBlockProductionOptionsRejectsInvalidMetadata(t *testing.T) { valid := `{"min_bid":"0","builder_boost_factor":"100","builders":[]}` for _, tc := range []struct { @@ -291,42 +328,76 @@ func TestDecodeGloasBlockProductionOptionsAcceptsMaximumJSONConfig(t *testing.T) require.NoError(t, err) } -func TestPostV4WithBidRouteRejectsMissingVersionBeforeProduction(t *testing.T) { - _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) - request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10/with_bid", strings.NewReader(`{}`)) - request.Header.Set("Content-Type", "application/json") - recorder := httptest.NewRecorder() +func TestPublishBlindedBlocksRejectsGloas(t *testing.T) { + _, _, _, _, _, h, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blinded_blocks", bytes.NewReader(nil)) + req.Header.Set("Eth-Consensus-Version", clparams.GloasVersion.String()) - handler.ServeHTTP(recorder, request) + _, err := h.publishBlindedBlocks(httptest.NewRecorder(), req, 2) + require.Error(t, err) + require.Contains(t, err.Error(), cltypes.ErrGloasCannotBlind.Error()) +} - require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) +func TestParseGloasPublishedBlockRejectsWrapperAndOversizeBodies(t *testing.T) { + _, _, _, _, _, h, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + h.beaconChainCfg.GloasForkEpoch = 0 + for _, tc := range []struct { + name string + body string + }{ + {name: "wrapper", body: `{"signed_block":{}}`}, + {name: "oversize", body: `{"message":{}}` + strings.Repeat(" ", maxGloasPublishedBlockSize)}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks", strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + _, err := h.parseGloasRequestBeaconBlock(clparams.GloasVersion, req) + require.Error(t, err) + }) + } } -func TestPostV4WithBidRejectsTrailingJSON(t *testing.T) { - _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) - handler.beaconChainCfg.GloasForkEpoch = 0 - bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 1)} - body, err := json.Marshal(bid) +func TestParseGloasPublishedBlockRejectsNonCanonicalSSZ(t *testing.T) { + _, _, _, _, _, h, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + h.beaconChainCfg.GloasForkEpoch = 0 + block := cltypes.NewSignedBeaconBlock(h.beaconChainCfg, clparams.GloasVersion) + encoded, err := block.EncodeSSZ(nil) require.NoError(t, err) - request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10/with_bid", strings.NewReader(string(body)+`{}`)) - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Eth-Consensus-Version", "gloas") - recorder := httptest.NewRecorder() - - handler.ServeHTTP(recorder, request) + encoded = append(encoded, 0) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks", bytes.NewReader(encoded)) + req.Header.Set("Content-Type", "application/octet-stream") - require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) - require.Contains(t, recorder.Body.String(), "trailing data") + _, err = h.parseGloasRequestBeaconBlock(clparams.GloasVersion, req) + require.Error(t, err) } -func TestPublishBlindedBlocksRejectsGloas(t *testing.T) { - _, _, _, _, _, h, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) - req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blinded_blocks", bytes.NewReader(nil)) - req.Header.Set("Eth-Consensus-Version", clparams.GloasVersion.String()) - - _, err := h.publishBlindedBlocks(httptest.NewRecorder(), req, 2) +func TestParseBlockPublishingValidationRejectsUnknownV2Value(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks?broadcast_validation=fast", nil) + _, err := (&ApiHandler{}).parseBlockPublishingValidation(req, 2) require.Error(t, err) - require.Contains(t, err.Error(), cltypes.ErrGloasCannotBlind.Error()) + + for _, value := range []BlockPublishingValidation{ + BlockPublishingValidationGossip, + BlockPublishingValidationConsensus, + BlockPublishingValidationConsensusAndEquivocation, + } { + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks?broadcast_validation="+string(value), nil) + got, err := (&ApiHandler{}).parseBlockPublishingValidation(req, 2) + require.NoError(t, err) + require.Equal(t, value, got) + } +} + +func TestBroadcastBlockRunsGossipValidationBeforePublishing(t *testing.T) { + ctrl := gomock.NewController(t) + blockService := network_services_mock.NewMockBlockService(ctrl) + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + validationErr := errors.New("invalid gossip block") + blockService.EXPECT().ValidateGossip(gomock.Any(), block).Return(validationErr) + + err := (&ApiHandler{blockService: blockService}).broadcastBlock(t.Context(), block, BlockPublishingValidationGossip) + require.ErrorIs(t, err, errPublishedBlockValidation) + require.ErrorContains(t, err, validationErr.Error()) } func TestPublishBlindedBlocksRejectsPreBellatrix(t *testing.T) { diff --git a/cl/beacon/handler/block_production_v4.go b/cl/beacon/handler/block_production_v4.go index 45b05182d71..7a0f08994a0 100644 --- a/cl/beacon/handler/block_production_v4.go +++ b/cl/beacon/handler/block_production_v4.go @@ -37,7 +37,6 @@ const maxBuilderConfigRequestSize = 2 << 20 type gloasBlockProductionOptions struct { builderConfig *cltypes.BuilderConfig includePayload bool - suppliedBid *cltypes.SignedExecutionPayloadBid selectedBuilderURL string } @@ -68,7 +67,7 @@ func decodeGloasBlockProductionOptions(w http.ResponseWriter, r *http.Request, t switch contentType { case "application/json": decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBuilderConfigRequestSize)) - if err := decoder.Decode(config); err != nil { + if err := decodeBuilderConfigJSON(decoder, config); err != nil { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) } if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { @@ -85,34 +84,46 @@ func decodeGloasBlockProductionOptions(w http.ResponseWriter, r *http.Request, t default: return nil, beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, fmt.Errorf("unsupported content type: %s", contentType)) } - for i, entry := range config.Builders { - if entry == nil || entry.Auth == nil || entry.Auth.Message == nil { - return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("builder %d has invalid auth", i)) - } - if entry.Auth.Message.Slot != targetSlot { - return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("builder %d auth slot %d does not match proposal slot %d", i, entry.Auth.Message.Slot, targetSlot)) + validBuilders := make([]*cltypes.BuilderEntry, 0, len(config.Builders)) + for _, entry := range config.Builders { + if entry == nil || entry.Auth == nil || entry.Auth.Message == nil || entry.Auth.Message.Slot != targetSlot { + continue } + validBuilders = append(validBuilders, entry) } + config.Builders = validBuilders return &gloasBlockProductionOptions{builderConfig: config, includePayload: includePayload}, nil } -func (a *ApiHandler) PostEthV4ValidatorBlock(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { - targetSlot, err := strconv.ParseUint(chi.URLParam(r, "slot"), 10, 64) - if err != nil { - return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid slot: %w", err)) - } - if a.beaconChainCfg.SlotsPerEpoch == 0 || targetSlot/a.beaconChainCfg.SlotsPerEpoch < a.beaconChainCfg.GloasForkEpoch { - return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("v4 block production is unavailable before Gloas")) - } - options, err := decodeGloasBlockProductionOptions(w, r, targetSlot) - if err != nil { - return nil, err +func decodeBuilderConfigJSON(decoder *json.Decoder, config *cltypes.BuilderConfig) error { + var raw struct { + MinBid *uint64 `json:"min_bid,string"` + BuilderBoostFactor *uint64 `json:"builder_boost_factor,string"` + Builders *[]json.RawMessage `json:"builders"` + } + decoder.DisallowUnknownFields() + if err := decoder.Decode(&raw); err != nil { + return err + } + if raw.MinBid == nil || raw.BuilderBoostFactor == nil || raw.Builders == nil { + return errors.New("builder config is missing a required field") + } + if len(*raw.Builders) > cltypes.MaxBuilderEntries { + return fmt.Errorf("builder count %d exceeds %d", len(*raw.Builders), cltypes.MaxBuilderEntries) + } + config.MinBid = *raw.MinBid + config.BuilderBoostFactor = *raw.BuilderBoostFactor + config.Builders = make([]*cltypes.BuilderEntry, 0, len(*raw.Builders)) + for _, encoded := range *raw.Builders { + entry := new(cltypes.BuilderEntry) + if err := json.Unmarshal(encoded, entry); err == nil { + config.Builders = append(config.Builders, entry) + } } - r = r.WithContext(context.WithValue(r.Context(), gloasBlockProductionOptionsKey{}, options)) - return a.GetEthV3ValidatorBlock(w, r) + return nil } -func (a *ApiHandler) PostEthV4ValidatorBlockWithBid(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { +func (a *ApiHandler) PostEthV4ValidatorBlock(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { targetSlot, err := strconv.ParseUint(chi.URLParam(r, "slot"), 10, 64) if err != nil { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid slot: %w", err)) @@ -120,55 +131,9 @@ func (a *ApiHandler) PostEthV4ValidatorBlockWithBid(w http.ResponseWriter, r *ht if a.beaconChainCfg.SlotsPerEpoch == 0 || targetSlot/a.beaconChainCfg.SlotsPerEpoch < a.beaconChainCfg.GloasForkEpoch { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("v4 block production is unavailable before Gloas")) } - if r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() { - return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")) - } - includePayload := false - if value := r.URL.Query().Get("include_payload"); value != "" { - includePayload, err = strconv.ParseBool(value) - if err != nil { - return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid include_payload: %w", err)) - } - } - boost := uint64(100) - if value := r.URL.Query().Get("builder_boost_factor"); value != "" { - boost, err = strconv.ParseUint(value, 10, 64) - if err != nil { - return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid builder_boost_factor: %w", err)) - } - } - bid := new(cltypes.SignedExecutionPayloadBid) - contentType, err := requestContentType(r) + options, err := decodeGloasBlockProductionOptions(w, r, targetSlot) if err != nil { - return nil, beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, err) - } - switch contentType { - case "application/json": - decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxEpbsJSONSize)) - if err := decoder.Decode(bid); err != nil { - return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) - } - if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { - return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("request body contains trailing data")) - } - case "application/octet-stream": - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxSignedExecutionPayloadBidSSZSize())) - if err != nil { - return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) - } - if err := bid.DecodeSSZ(body, int(clparams.GloasVersion)); err != nil { - return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) - } - default: - return nil, beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, fmt.Errorf("unsupported content type: %s", contentType)) - } - if bid.Message == nil || bid.Message.Slot != targetSlot { - return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("execution payload bid slot does not match proposal slot")) - } - options := &gloasBlockProductionOptions{ - builderConfig: &cltypes.BuilderConfig{BuilderBoostFactor: boost}, - includePayload: includePayload, - suppliedBid: bid, + return nil, err } r = r.WithContext(context.WithValue(r.Context(), gloasBlockProductionOptionsKey{}, options)) return a.GetEthV3ValidatorBlock(w, r) diff --git a/cl/beacon/handler/builder_preferences.go b/cl/beacon/handler/builder_preferences.go index 2627b019963..f525cd1b87c 100644 --- a/cl/beacon/handler/builder_preferences.go +++ b/cl/beacon/handler/builder_preferences.go @@ -17,18 +17,27 @@ package handler import ( + "context" "encoding/json" "errors" "fmt" "io" "net/http" + "sort" + "sync" + "time" "github.com/erigontech/erigon/cl/beacon/beaconhttp" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" ) -const maxBuilderPreferencesRequestSize = 64 << 20 +const ( + maxBuilderPreferencesRequestSize = 64 << 20 + maxBuilderFailureMessageSize = 256 + builderPreferencesWorkers = 32 + builderPreferencesRequestTimeout = 5 * time.Second +) func (a *ApiHandler) PostEthV1ValidatorBuilderPreferences(w http.ResponseWriter, r *http.Request) { version, err := clparams.StringToClVersion(r.Header.Get("Eth-Consensus-Version")) @@ -41,6 +50,12 @@ func (a *ApiHandler) PostEthV1ValidatorBuilderPreferences(w http.ResponseWriter, return } entries := cltypes.BuilderPreferencesEntries{} + type indexedEntry struct { + index int + entry *cltypes.BuilderPreferencesEntry + } + var indexedEntries []indexedEntry + failures := make([]poolingFailure, 0) contentType, err := requestContentType(r) if err != nil { beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, err).WriteTo(w) @@ -49,7 +64,8 @@ func (a *ApiHandler) PostEthV1ValidatorBuilderPreferences(w http.ResponseWriter, switch contentType { case "application/json": decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBuilderPreferencesRequestSize)) - if err := decoder.Decode(&entries); err != nil { + var rawEntries []json.RawMessage + if err := decoder.Decode(&rawEntries); err != nil { beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) return } @@ -57,6 +73,23 @@ func (a *ApiHandler) PostEthV1ValidatorBuilderPreferences(w http.ResponseWriter, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("request body contains trailing data")).WriteTo(w) return } + if rawEntries == nil { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("builder preferences entries cannot be null")).WriteTo(w) + return + } + if len(rawEntries) > cltypes.MaxBuilderPreferencesEntries { + beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("builder preferences entry count %d exceeds %d", len(rawEntries), cltypes.MaxBuilderPreferencesEntries)).WriteTo(w) + return + } + indexedEntries = make([]indexedEntry, 0, len(rawEntries)) + for i, rawEntry := range rawEntries { + entry := new(cltypes.BuilderPreferencesEntry) + if err := json.Unmarshal(rawEntry, entry); err != nil { + failures = append(failures, poolingFailure{Index: i, Message: builderFailureMessage(err)}) + continue + } + indexedEntries = append(indexedEntries, indexedEntry{index: i, entry: entry}) + } case "application/octet-stream": body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBuilderPreferencesRequestSize)) if err != nil { @@ -67,24 +100,58 @@ func (a *ApiHandler) PostEthV1ValidatorBuilderPreferences(w http.ResponseWriter, beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) return } + indexedEntries = make([]indexedEntry, len(entries)) + for i, entry := range entries { + indexedEntries[i] = indexedEntry{index: i, entry: entry} + } default: beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, fmt.Errorf("unsupported content type: %s", contentType)).WriteTo(w) return } - failures := make([]poolingFailure, 0) - for i, entry := range entries { - request := &cltypes.BuilderPreferencesRequest{ - Preferences: &cltypes.BuilderPreferences{MaxExecutionPayment: entry.MaxExecutionPayment}, - Auth: entry.Auth, - } - if err := a.builderClient.SubmitBuilderPreferences(r.Context(), entry.URL, entry.ProposerPubkey, request); err != nil { - failures = append(failures, poolingFailure{Index: i, Message: err.Error()}) - } + requestContext, cancel := context.WithTimeout(r.Context(), builderPreferencesRequestTimeout) + defer cancel() + jobs := make(chan indexedEntry, len(indexedEntries)) + results := make(chan poolingFailure, len(indexedEntries)) + workerCount := min(builderPreferencesWorkers, len(indexedEntries)) + var workers sync.WaitGroup + workers.Add(workerCount) + for range workerCount { + go func() { + defer workers.Done() + for indexed := range jobs { + entry := indexed.entry + request := &cltypes.BuilderPreferencesRequest{ + Preferences: &cltypes.BuilderPreferences{MaxExecutionPayment: entry.MaxExecutionPayment}, + Auth: entry.Auth, + } + if err := a.builderClient.SubmitBuilderPreferences(requestContext, entry.URL, entry.ProposerPubkey, request); err != nil { + results <- poolingFailure{Index: indexed.index, Message: builderFailureMessage(err)} + } + } + }() + } + for _, indexed := range indexedEntries { + jobs <- indexed } + close(jobs) + workers.Wait() + close(results) + for failure := range results { + failures = append(failures, failure) + } + sort.Slice(failures, func(i, j int) bool { return failures[i].Index < failures[j].Index }) if len(failures) != 0 { a.writePoolingFailures(w, failures) return } w.WriteHeader(http.StatusOK) } + +func builderFailureMessage(err error) string { + message := err.Error() + if len(message) > maxBuilderFailureMessageSize { + return message[:maxBuilderFailureMessageSize] + } + return message +} diff --git a/cl/beacon/handler/builder_preferences_test.go b/cl/beacon/handler/builder_preferences_test.go index 121354173bd..5cb8899e1c8 100644 --- a/cl/beacon/handler/builder_preferences_test.go +++ b/cl/beacon/handler/builder_preferences_test.go @@ -18,11 +18,14 @@ package handler import ( "bytes" + "context" + "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" + "time" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -114,3 +117,82 @@ func TestPostValidatorBuilderPreferencesAcceptsMaximumJSONList(t *testing.T) { require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) } + +func TestPostValidatorBuilderPreferencesReportsMalformedJSONEntryAndContinues(t *testing.T) { + ctrl := gomock.NewController(t) + client := mock_services.NewMockBuilderClient(ctrl) + valid := testBuilderPreferencesEntries()[1] + validJSON, err := valid.MarshalJSON() + require.NoError(t, err) + client.EXPECT().SubmitBuilderPreferences(gomock.Any(), valid.URL, valid.ProposerPubkey, gomock.Any()).Return(nil) + handler := &ApiHandler{builderClient: client} + body := append([]byte(`[{"url":7},`), validJSON...) + body = append(body, ']') + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/builder_preferences", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1ValidatorBuilderPreferences(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, recorder.Body.String(), `"index":0`) +} + +func TestPostValidatorBuilderPreferencesRejectsStructurallyInvalidJSON(t *testing.T) { + ctrl := gomock.NewController(t) + handler := &ApiHandler{builderClient: mock_services.NewMockBuilderClient(ctrl)} + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/builder_preferences", strings.NewReader(`[{"url":7}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1ValidatorBuilderPreferences(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.NotContains(t, recorder.Body.String(), `"failures"`) +} + +func TestPostValidatorBuilderPreferencesBoundsSlowEntriesAndContinues(t *testing.T) { + ctrl := gomock.NewController(t) + client := mock_services.NewMockBuilderClient(ctrl) + entries := make(cltypes.BuilderPreferencesEntries, 40) + for i := range entries { + entries[i] = testBuilderPreferencesEntries()[0].Clone().(*cltypes.BuilderPreferencesEntry) + entries[i].ProposerPubkey[0] = byte(i + 1) + } + client.EXPECT().SubmitBuilderPreferences(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, _ string, proposer common.Bytes48, _ *cltypes.BuilderPreferencesRequest) error { + if proposer == entries[len(entries)-1].ProposerPubkey { + return nil + } + select { + case <-time.After(100 * time.Millisecond): + return errors.New("slow failure") + case <-ctx.Done(): + return ctx.Err() + } + }, + ).Times(len(entries)) + handler := &ApiHandler{builderClient: client} + body, err := entries.MarshalJSON() + require.NoError(t, err) + requestContext, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + request := httptest.NewRequestWithContext(requestContext, http.MethodPost, "/eth/v1/validator/builder_preferences", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + started := time.Now() + + handler.PostEthV1ValidatorBuilderPreferences(recorder, request) + + require.Less(t, time.Since(started), 500*time.Millisecond) + require.Equal(t, http.StatusBadRequest, recorder.Code) + var response poolingError + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Len(t, response.Failures, len(entries)-1) + for i, failure := range response.Failures { + require.Equal(t, i, failure.Index) + } +} diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index 47abe67c31e..14c742e5b10 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -814,22 +814,42 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("missing message in signed envelope")).WriteTo(w) return } + contentsIntegrationFailed := false if contents != nil { - if err := a.storeExecutionPayloadEnvelopeContents(r.Context(), contents); err != nil { - beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) - return + if err := a.validateAndStoreExecutionPayloadEnvelopeContents(r.Context(), contents); err != nil { + if !errors.Is(err, errExecutionPayloadEnvelopeIntegration) { + beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + return + } + contentsIntegrationFailed = true } } status := http.StatusOK + if contentsIntegrationFailed { + status = http.StatusAccepted + } + gossipValidated := false if err := a.forkchoiceStore.OnExecutionPayload(r.Context(), signedEnvelope, canonical, true); err != nil { if errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) { a.logger.Debug("[Beacon REST] OnExecutionPayload queued or ignored", "err", err) status = http.StatusAccepted + gossipValidated = errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) } else { beaconhttp.WrapEndpointError(err).WriteTo(w) return } + } else { + gossipValidated = true + } + if a.emitters != nil && gossipValidated && signedEnvelope.Message.Payload != nil { + block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot) + if ok && block != nil && block.Block != nil { + a.emitters.Operation().SendExecutionPayloadGossip(&beaconevents.ExecutionPayloadGossipData{ + Slot: block.Block.Slot, BuilderIndex: signedEnvelope.Message.BuilderIndex, + BlockHash: signedEnvelope.Message.Payload.BlockHash, BlockRoot: signedEnvelope.Message.BeaconBlockRoot, + }) + } } if status == http.StatusOK && a.emitters != nil { block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot) @@ -870,28 +890,15 @@ func (a *ApiHandler) emitFullHeadV2(block *cltypes.SignedBeaconBlock, blockRoot if err != nil || headState == nil { return } - epoch := headSlot / a.beaconChainCfg.SlotsPerEpoch - currentRoot, nextRoot := a.forkchoiceStore.AnchorRoot(), a.forkchoiceStore.AnchorRoot() - if epoch > 1 { - currentRoot, err = headState.GetBlockRootAtSlot((epoch-1)*a.beaconChainCfg.SlotsPerEpoch - 1) - if err != nil { - return - } + event, err := beaconevents.BuildHeadV2Data(a.beaconChainCfg, headState, headSlot, headRoot, block.Block.StateRoot, "full", a.forkchoiceStore.IsRootOptimistic(blockRoot)) + if err != nil { + return } - if epoch > 0 { - nextRoot, err = headState.GetBlockRootAtSlot(epoch*a.beaconChainCfg.SlotsPerEpoch - 1) - if err != nil { - return - } + currentRoot, currentSlot, err := a.forkchoiceStore.GetHead(nil) + if err != nil || currentRoot != headRoot || currentSlot != headSlot { + return } - a.emitters.State().SendHeadV2(&beaconevents.HeadV2Data{ - Version: clparams.GloasVersion.String(), - Data: beaconevents.HeadV2Content{ - Slot: headSlot, Block: headRoot, State: block.Block.StateRoot, PayloadStatus: "full", - EpochTransition: headSlot%a.beaconChainCfg.SlotsPerEpoch == 0, CurrentEpochDependentRoot: currentRoot, - NextEpochDependentRoot: nextRoot, ExecutionOptimistic: a.forkchoiceStore.IsRootOptimistic(blockRoot), - }, - }) + a.emitters.State().SendHeadV2(event) } func (a *ApiHandler) decodeExecutionPayloadEnvelopeRequest(w http.ResponseWriter, r *http.Request, contentType string, blobDataIncluded bool) (*cltypes.SignedExecutionPayloadEnvelope, *cltypes.SignedExecutionPayloadEnvelopeContents, error) { @@ -934,6 +941,18 @@ func (a *ApiHandler) decodeExecutionPayloadEnvelopeRequest(w http.ResponseWriter return signedEnvelope, contents, nil } +func (a *ApiHandler) validateAndStoreExecutionPayloadEnvelopeContents(ctx context.Context, contents *cltypes.SignedExecutionPayloadEnvelopeContents) error { + if contents == nil || contents.SignedExecutionPayloadEnvelope == nil { + return errors.New("execution payload envelope contents has nil envelope") + } + if err := a.forkchoiceStore.ValidateExecutionPayloadEnvelope(contents.SignedExecutionPayloadEnvelope); err != nil { + return err + } + return a.storeExecutionPayloadEnvelopeContents(ctx, contents) +} + +var errExecutionPayloadEnvelopeIntegration = errors.New("execution payload envelope integration failed") + func (a *ApiHandler) storeExecutionPayloadEnvelopeContents(ctx context.Context, contents *cltypes.SignedExecutionPayloadEnvelopeContents) error { if contents == nil || contents.SignedExecutionPayloadEnvelope == nil || contents.SignedExecutionPayloadEnvelope.Message == nil { return errors.New("execution payload envelope contents has nil envelope") @@ -986,15 +1005,18 @@ func (a *ApiHandler) storeExecutionPayloadEnvelopeContents(ctx context.Context, return fmt.Errorf("execution payload envelope column %d has invalid KZG proof", column.Index) } } + if len(columns) != 0 && a.columnStorage == nil { + return fmt.Errorf("%w: data column storage unavailable", errExecutionPayloadEnvelopeIntegration) + } + for _, column := range columns { + if err := a.columnStorage.WriteColumnSidecars(ctx, envelope.BeaconBlockRoot, int64(column.Index), column); err != nil { + return fmt.Errorf("%w: %w", errExecutionPayloadEnvelopeIntegration, err) + } + } for _, bundle := range bundles { a.blobBundles.Add(bundle.Commitment, bundle) } for _, column := range columns { - if a.columnStorage != nil { - if err := a.columnStorage.WriteColumnSidecars(ctx, envelope.BeaconBlockRoot, int64(column.Index), column); err != nil { - return err - } - } if a.sentinel != nil { encoded, err := column.EncodeSSZ(nil) if err != nil { @@ -1177,15 +1199,10 @@ func (a *ApiHandler) GetEthV1ValidatorExecutionPayloadEnvelope(w http.ResponseWr fmt.Errorf("execution payload envelopes not available before GLOAS fork")) } - // Look up the cached self-build envelope for this slot. - envelope, ok := a.selfBuildEnvelopes.Get(slot) + envelope, ok := a.selfBuildEnvelopeForSlot(slot, func(envelope *cltypes.ExecutionPayloadEnvelope) bool { + return envelope.BuilderIndex == builderIndex + }) if !ok || envelope == nil { - return nil, beaconhttp.NewEndpointError(http.StatusNotFound, - fmt.Errorf("no execution payload envelope found for slot %d", slot)) - } - - // Validate that the requested builder_index matches the cached envelope. - if envelope.BuilderIndex != builderIndex { return nil, beaconhttp.NewEndpointError(http.StatusNotFound, fmt.Errorf("no execution payload envelope found for slot %d with builder_index %d", slot, builderIndex)) } @@ -1213,7 +1230,7 @@ func (a *ApiHandler) GetEthV1ValidatorExecutionPayloadEnvelopeBySlot(w http.Resp fmt.Errorf("execution payload envelopes not available before GLOAS fork")) } - envelope, ok := a.selfBuildEnvelopes.Get(slot) + envelope, ok := a.selfBuildEnvelopeForSlot(slot, nil) if !ok || envelope == nil { return nil, beaconhttp.NewEndpointError(http.StatusNotFound, fmt.Errorf("no execution payload envelope found for slot %d", slot)) @@ -1245,13 +1262,27 @@ func (a *ApiHandler) GetEthV1ValidatorExecutionPayloadEnvelopeByBlockRoot(w http if slot/a.beaconChainCfg.SlotsPerEpoch < a.beaconChainCfg.GloasForkEpoch { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("execution payload envelopes not available before GLOAS fork")) } - envelope, ok := a.selfBuildEnvelopes.Get(slot) - if !ok || envelope == nil || envelope.BeaconBlockRoot != root { + envelope, ok := a.selfBuildEnvelopes.Get(selfBuildEnvelopeKey{Slot: slot, BeaconBlockRoot: root}) + if !ok || envelope == nil { return nil, beaconhttp.NewEndpointError(http.StatusNotFound, fmt.Errorf("no execution payload envelope found for slot %d and block root %s", slot, root)) } return newBeaconResponse(envelope).WithVersion(clparams.GloasVersion), nil } +func (a *ApiHandler) selfBuildEnvelopeForSlot(slot uint64, accept func(*cltypes.ExecutionPayloadEnvelope) bool) (*cltypes.ExecutionPayloadEnvelope, bool) { + keys := a.selfBuildEnvelopes.Keys() + for _, key := range slices.Backward(keys) { + if key.Slot != slot { + continue + } + envelope, ok := a.selfBuildEnvelopes.Get(key) + if ok && envelope != nil && (accept == nil || accept(envelope)) { + return envelope, true + } + } + return nil, false +} + // ---- Helpers ---- // blockRootFromBlockId resolves a block_id to a block root hash. diff --git a/cl/beacon/handler/epbs_test.go b/cl/beacon/handler/epbs_test.go index 92e475dd5ae..ceb4f496b3f 100644 --- a/cl/beacon/handler/epbs_test.go +++ b/cl/beacon/handler/epbs_test.go @@ -35,6 +35,7 @@ import ( "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" + "github.com/erigontech/erigon/cl/phase1/forkchoice" "github.com/erigontech/erigon/cl/phase1/network/services" mock_services "github.com/erigontech/erigon/cl/phase1/network/services/mock_services" "github.com/erigontech/erigon/cl/pool" @@ -213,7 +214,7 @@ func TestPostExecutionPayloadEnvelopesRejectsTrailingJSON(t *testing.T) { func TestPostExecutionPayloadEnvelopesEmitsImportedAndAvailableEvents(t *testing.T) { _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) handler.emitters = beaconevents.NewEventEmitter() - events := make(chan *beaconevents.EventStream, 2) + events := make(chan *beaconevents.EventStream, 3) subscription := handler.emitters.Operation().Subscribe(events) defer subscription.Unsubscribe() @@ -235,6 +236,7 @@ func TestPostExecutionPayloadEnvelopesEmitsImportedAndAvailableEvents(t *testing handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, beaconevents.OpExecutionPayloadGossip, (<-events).Event) require.Equal(t, beaconevents.OpExecutionPayload, (<-events).Event) select { case event := <-events: @@ -244,6 +246,40 @@ func TestPostExecutionPayloadEnvelopesEmitsImportedAndAvailableEvents(t *testing } } +func TestPostExecutionPayloadEnvelopesEmitsGossipWhenWaitingForColumns(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.emitters = beaconevents.NewEventEmitter() + events := make(chan *beaconevents.EventStream, 2) + subscription := handler.emitters.Operation().Subscribe(events) + defer subscription.Unsubscribe() + + root := common.Hash{2} + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + envelope.Message.BeaconBlockRoot = root + envelope.Message.BuilderIndex = 4 + fcu.Blocks = map[common.Hash]*cltypes.SignedBeaconBlock{ + root: {Block: &cltypes.BeaconBlock{Slot: 13, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}, + } + fcu.OnExecutionPayloadErr = forkchoice.ErrEIP7594ColumnDataNotAvailable + body, err := json.Marshal(envelope) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", strings.NewReader(string(body))) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Blob-Data-Included", "false") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + + require.Equal(t, http.StatusAccepted, recorder.Code, recorder.Body.String()) + require.Equal(t, beaconevents.OpExecutionPayloadGossip, (<-events).Event) + select { + case event := <-events: + t.Fatalf("unexpected event %s", event.Event) + default: + } +} + func TestPostPtcDutiesDoesNotCapValidatorCount(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) handler.beaconChainCfg.GloasForkEpoch = 0 @@ -626,7 +662,7 @@ func TestGetValidatorExecutionPayloadEnvelopesBySlot(t *testing.T) { slot := uint64(3) envelope := cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg) envelope.BuilderIndex = 7 - handler.selfBuildEnvelopes.Add(slot, envelope) + handler.selfBuildEnvelopes.Add(selfBuildEnvelopeKey{Slot: slot, BeaconBlockRoot: envelope.BeaconBlockRoot}, envelope) request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/execution_payload_envelopes/3", http.NoBody) recorder := httptest.NewRecorder() @@ -644,7 +680,11 @@ func TestGetValidatorExecutionPayloadEnvelopeByBlockRoot(t *testing.T) { root := common.HexToHash("0x1234") envelope := cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg) envelope.BeaconBlockRoot = root - handler.selfBuildEnvelopes.Add(slot, envelope) + handler.selfBuildEnvelopes.Add(selfBuildEnvelopeKey{Slot: slot, BeaconBlockRoot: root}, envelope) + otherRoot := common.HexToHash("0x5678") + otherEnvelope := cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg) + otherEnvelope.BeaconBlockRoot = otherRoot + handler.selfBuildEnvelopes.Add(selfBuildEnvelopeKey{Slot: slot, BeaconBlockRoot: otherRoot}, otherEnvelope) tests := []struct { name string @@ -653,7 +693,8 @@ func TestGetValidatorExecutionPayloadEnvelopeByBlockRoot(t *testing.T) { want int }{ {name: "matching current slot and root", slot: slot, root: root, want: http.StatusOK}, - {name: "wrong root", slot: slot, root: common.HexToHash("0x5678"), want: http.StatusNotFound}, + {name: "same slot alternate root", slot: slot, root: otherRoot, want: http.StatusOK}, + {name: "wrong root", slot: slot, root: common.HexToHash("0x9999"), want: http.StatusNotFound}, {name: "old slot", slot: slot - 1, root: root, want: http.StatusNotFound}, } for _, tt := range tests { diff --git a/cl/beacon/handler/epbs_validation_test.go b/cl/beacon/handler/epbs_validation_test.go new file mode 100644 index 00000000000..706213e7fc2 --- /dev/null +++ b/cl/beacon/handler/epbs_validation_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package handler + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cl/clparams" + "github.com/erigontech/erigon/cl/cltypes" + "github.com/erigontech/erigon/common/log/v3" +) + +func TestEnvelopeContentsAuthenticateBeforeBlobProcessing(t *testing.T) { + _, _, _, _, _, handler, _, _, forkchoice, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + want := errors.New("invalid envelope signature") + forkchoice.ValidateExecutionPayloadEnvelopeErr = want + contents := cltypes.NewSignedExecutionPayloadEnvelopeContents(handler.beaconChainCfg, 0) + + err := handler.validateAndStoreExecutionPayloadEnvelopeContents(t.Context(), contents) + + require.ErrorIs(t, err, want) +} diff --git a/cl/beacon/handler/events.go b/cl/beacon/handler/events.go index d3a37abdf6d..bcb154232af 100644 --- a/cl/beacon/handler/events.go +++ b/cl/beacon/handler/events.go @@ -57,7 +57,6 @@ var validTopics = map[event.EventTopic]struct{}{ event.StateHeadV2: {}, event.StateLightClientOptimisticUpdate: {}, event.StatePayloadAttributes: {}, - event.StateFastConfirmation: {}, } func (a *ApiHandler) EventSourceGetV1Events(w http.ResponseWriter, r *http.Request) { diff --git a/cl/beacon/handler/events_gloas_test.go b/cl/beacon/handler/events_gloas_test.go index 0e685ff65b6..2b92319b6b0 100644 --- a/cl/beacon/handler/events_gloas_test.go +++ b/cl/beacon/handler/events_gloas_test.go @@ -38,3 +38,8 @@ func TestGloasEventTopicsAreValid(t *testing.T) { require.True(t, ok, topic) } } + +func TestFastConfirmationTopicIsNotAdvertisedBeforeItHasAProducer(t *testing.T) { + _, ok := validTopics[beaconevents.EventTopic("fast_confirmation")] + require.False(t, ok) +} diff --git a/cl/beacon/handler/handler.go b/cl/beacon/handler/handler.go index 051c61cb8ed..60b1d93fd52 100644 --- a/cl/beacon/handler/handler.go +++ b/cl/beacon/handler/handler.go @@ -74,6 +74,11 @@ type selfBuildPayload struct { ExecutionRequests *cltypes.ExecutionRequests } +type selfBuildEnvelopeKey struct { + Slot uint64 + BeaconBlockRoot common.Hash +} + type ApiHandler struct { o sync.Once mux *chi.Mux @@ -132,6 +137,7 @@ type ApiHandler struct { voluntaryExitService services.VoluntaryExitService blsToExecutionChangeService services.BLSToExecutionChangeService proposerSlashingService services.ProposerSlashingService + blockService services.BlockService builderClient builder.BuilderClient gossipManager gossip.Gossip enableMemoizedHeadState bool @@ -152,7 +158,7 @@ type ApiHandler struct { // GET /eth/v1/validator/execution_payload_envelope/{slot}/{builder_index}. // Populated during block production alongside selfBuildPayloads. // [New in Gloas:EIP7732] - selfBuildEnvelopes *lru.Cache[uint64, *cltypes.ExecutionPayloadEnvelope] + selfBuildEnvelopes *lru.Cache[selfBuildEnvelopeKey, *cltypes.ExecutionPayloadEnvelope] builderRoutes *lru.Cache[common.Hash, string] } @@ -187,6 +193,7 @@ func NewApiHandler( voluntaryExitService services.VoluntaryExitService, blsToExecutionChangeService services.BLSToExecutionChangeService, proposerSlashingService services.ProposerSlashingService, + blockService services.BlockService, builderClient builder.BuilderClient, caplinStateSnapshots *snapshotsync.CaplinStateSnapshots, gossipManager gossip.Gossip, @@ -218,7 +225,7 @@ func NewApiHandler( if err != nil { panic(err) } - selfBuildEnvelopes, err := lru.New[uint64, *cltypes.ExecutionPayloadEnvelope]("selfBuildEnvelopes", 4) + selfBuildEnvelopes, err := lru.New[selfBuildEnvelopeKey, *cltypes.ExecutionPayloadEnvelope]("selfBuildEnvelopes", 4) if err != nil { panic(err) } @@ -266,6 +273,7 @@ func NewApiHandler( voluntaryExitService: voluntaryExitService, blsToExecutionChangeService: blsToExecutionChangeService, proposerSlashingService: proposerSlashingService, + blockService: blockService, builderClient: builderClient, gossipManager: gossipManager, enableMemoizedHeadState: enableMemoizedHeadState, @@ -482,7 +490,6 @@ func (a *ApiHandler) init() { r.Get("/v3/validator/blocks/{slot}", beaconhttp.HandleEndpointFunc(a.GetEthV3ValidatorBlock)) r.Get("/v4/validator/blocks/{slot}", beaconhttp.HandleEndpointFunc(a.GetEthV3ValidatorBlock)) r.Post("/v4/validator/blocks/{slot}", beaconhttp.HandleEndpointFunc(a.PostEthV4ValidatorBlock)) - r.Post("/v4/validator/blocks/{slot}/with_bid", beaconhttp.HandleEndpointFunc(a.PostEthV4ValidatorBlockWithBid)) } }) } diff --git a/cl/beacon/handler/utils_test.go b/cl/beacon/handler/utils_test.go index b1de28752ed..a437522eace 100644 --- a/cl/beacon/handler/utils_test.go +++ b/cl/beacon/handler/utils_test.go @@ -127,6 +127,8 @@ func setupTestingHandler(t *testing.T, v clparams.StateVersion, logger log.Logge voluntaryExitService := mock_services.NewMockVoluntaryExitService(ctrl) blsToExecutionChangeService := mock_services.NewMockBLSToExecutionChangeService(ctrl) proposerSlashingService := mock_services.NewMockProposerSlashingService(ctrl) + blockService := mock_services.NewMockBlockService(ctrl) + blockService.EXPECT().ValidateGossip(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() // ctx context.Context, subnetID *uint64, msg *cltypes.SyncCommitteeMessage) error syncCommitteeMessagesService.EXPECT().ProcessMessage(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, subnetID *uint64, msg *services.SyncCommitteeMessageForGossip) error { @@ -187,6 +189,7 @@ func setupTestingHandler(t *testing.T, v clparams.StateVersion, logger log.Logge voluntaryExitService, blsToExecutionChangeService, proposerSlashingService, + blockService, nil, nil, gossipManager, diff --git a/cl/beacon/handler/validator_test.go b/cl/beacon/handler/validator_test.go index 2b45245d4ea..0aed57fad3d 100644 --- a/cl/beacon/handler/validator_test.go +++ b/cl/beacon/handler/validator_test.go @@ -77,6 +77,7 @@ func (t *validatorTestSuite) SetupTest() { nil, nil, nil, + nil, false, nil, nil, diff --git a/cl/cltypes/beacon_block.go b/cl/cltypes/beacon_block.go index 6a246586b1e..9854c1b2baa 100644 --- a/cl/cltypes/beacon_block.go +++ b/cl/cltypes/beacon_block.go @@ -1050,10 +1050,6 @@ type DenebSignedBeaconBlock struct { SignedBlock *SignedBeaconBlock `json:"signed_block"` KZGProofs *solid.ListSSZ[*KZGProof] `json:"kzg_proofs"` Blobs *solid.ListSSZ[*Blob] `json:"blobs"` - // SignedExecutionPayloadEnvelope is the validator-signed envelope for GLOAS self-build blocks. - // Present only on POST /eth/v2/beacon/blocks when the validator client submits a signed - // envelope alongside the signed block. [New in Gloas:EIP7732] - SignedExecutionPayloadEnvelope *SignedExecutionPayloadEnvelope `json:"signed_execution_payload_envelope,omitempty"` } func NewDenebSignedBeaconBlock(beaconCfg *clparams.BeaconChainConfig, version clparams.StateVersion) *DenebSignedBeaconBlock { diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go b/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go index 8541a13fc8b..1282067ca1a 100644 --- a/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go +++ b/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go @@ -444,6 +444,26 @@ func (f *forkGraphDisk) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint6 return children != nil && !isBelowPrunedBoundary(children.maxSlot, f.lowestAvailableBlock.Load()) && children.maxSlot >= slot } +func (f *forkGraphDisk) HasBlockEquivocation(slot, proposerIndex uint64, exceptRoot common.Hash) bool { + if slot < f.LowestAvailableSlot() { + return false + } + found := false + f.blocks.Range(func(key, value any) bool { + root, ok := key.(common.Hash) + if !ok || root == exceptRoot { + return true + } + block, ok := value.(*cltypes.SignedBeaconBlock) + if ok && block != nil && block.Block != nil && block.Block.Slot == slot && block.Block.ProposerIndex == proposerIndex { + found = true + return false + } + return true + }) + return found +} + func (f *forkGraphDisk) addValidatedChild(parentRoot, childRoot common.Hash, slot uint64) { f.childrenMu.Lock() defer f.childrenMu.Unlock() diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_test.go b/cl/phase1/forkchoice/fork_graph/fork_graph_test.go index bcb31b28770..e7074bf9150 100644 --- a/cl/phase1/forkchoice/fork_graph/fork_graph_test.go +++ b/cl/phase1/forkchoice/fork_graph/fork_graph_test.go @@ -336,6 +336,19 @@ func TestHasBlockChildAtOrAfterUsesValidatedChildren(t *testing.T) { require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 64)) } +func TestHasBlockEquivocation(t *testing.T) { + f := &forkGraphDisk{} + root := common.Hash{1} + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + block.Block.Slot = 64 + block.Block.ProposerIndex = 9 + f.blocks.Store(root, block) + + require.True(t, f.HasBlockEquivocation(64, 9, common.Hash{2})) + require.False(t, f.HasBlockEquivocation(64, 9, root)) + require.False(t, f.HasBlockEquivocation(64, 8, common.Hash{2})) +} + func TestRemoveValidatedChildrenBulkKeepsSameSlotSurvivor(t *testing.T) { f := &forkGraphDisk{children: make(map[common.Hash]*validatedChildren)} parentRoot := common.Hash{1} diff --git a/cl/phase1/forkchoice/fork_graph/interface.go b/cl/phase1/forkchoice/fork_graph/interface.go index 7dedacee35a..72f66776ed8 100644 --- a/cl/phase1/forkchoice/fork_graph/interface.go +++ b/cl/phase1/forkchoice/fork_graph/interface.go @@ -40,6 +40,7 @@ type ForkGraph interface { GetHeader(blockRoot common.Hash) (*cltypes.BeaconBlockHeader, bool) GetBlock(blockRoot common.Hash) (*cltypes.SignedBeaconBlock, bool) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool + HasBlockEquivocation(slot, proposerIndex uint64, exceptRoot common.Hash) bool GetState(blockRoot common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) GetCurrentJustifiedCheckpoint(blockRoot common.Hash) (solid.Checkpoint, bool) GetFinalizedCheckpoint(blockRoot common.Hash) (solid.Checkpoint, bool) diff --git a/cl/phase1/forkchoice/forkchoice.go b/cl/phase1/forkchoice/forkchoice.go index 4612826bf82..09e1f96c0c2 100644 --- a/cl/phase1/forkchoice/forkchoice.go +++ b/cl/phase1/forkchoice/forkchoice.go @@ -783,6 +783,10 @@ func (f *ForkChoiceStore) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uin return f.forkGraph.HasBlockChildAtOrAfter(blockRoot, slot) } +func (f *ForkChoiceStore) HasBlockEquivocation(slot, proposerIndex uint64, exceptRoot common.Hash) bool { + return f.forkGraph.HasBlockEquivocation(slot, proposerIndex, exceptRoot) +} + // HasEnvelope delegates to forkGraph.HasEnvelope. // [New in Gloas:EIP7732] func (f *ForkChoiceStore) HasEnvelope(blockRoot common.Hash) bool { diff --git a/cl/phase1/forkchoice/forkchoice_test.go b/cl/phase1/forkchoice/forkchoice_test.go index 64847e92d0e..694a58d0fe9 100644 --- a/cl/phase1/forkchoice/forkchoice_test.go +++ b/cl/phase1/forkchoice/forkchoice_test.go @@ -424,6 +424,10 @@ type getFinalizedExecutionHashForkGraph struct { currentJustified solid.Checkpoint } +func (f *getFinalizedExecutionHashForkGraph) HasBlockEquivocation(uint64, uint64, common.Hash) bool { + return false +} + func (g *getFinalizedExecutionHashForkGraph) AddChainSegment(*cltypes.SignedBeaconBlock, bool) (*state.CachingBeaconState, fork_graph.ChainSegmentInsertionResult, error) { g.addChainSegmentCalled = true return nil, g.addChainSegmentStatus, g.addChainSegmentErr diff --git a/cl/phase1/forkchoice/interface.go b/cl/phase1/forkchoice/interface.go index 3a90c38828c..de15c8c32d4 100644 --- a/cl/phase1/forkchoice/interface.go +++ b/cl/phase1/forkchoice/interface.go @@ -75,6 +75,7 @@ type ForkChoiceStorageReader interface { // [New in Gloas:EIP7732] GetBlock returns the full block for a given block root. GetBlock(blockRoot common.Hash) (*cltypes.SignedBeaconBlock, bool) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool + HasBlockEquivocation(slot, proposerIndex uint64, exceptRoot common.Hash) bool // [New in Gloas:EIP7732] HasEnvelope checks if a signed execution payload envelope exists. HasEnvelope(blockRoot common.Hash) bool // IsPayloadVerified reports whether the EL has fully validated the payload. @@ -135,10 +136,18 @@ type ForkChoiceStorageWriter interface { fullValidation bool, checkDataAvaibility bool, ) error + OnBlockWithEquivocationCheck( + ctx context.Context, + block *cltypes.SignedBeaconBlock, + newPayload bool, + fullValidation bool, + checkDataAvaibility bool, + ) error // [New in Gloas:EIP7732] OnExecutionPayload processes an execution payload envelope from the builder. // checkBlobData: verify blob data availability via PeerDAS // validatePayload: call engine.NewPayload() to validate with EL OnExecutionPayload(ctx context.Context, signedEnvelope *cltypes.SignedExecutionPayloadEnvelope, checkBlobData, validatePayload bool) error + ValidateExecutionPayloadEnvelope(signedEnvelope *cltypes.SignedExecutionPayloadEnvelope) error // [New in Gloas:EIP7732] ApplyLocalSelfBuildEnvelope processes a locally-produced // self-build envelope, skipping BLS signature verification. EL validation still runs. // MUST only be called from the local block production path. diff --git a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go index c96a2ef6479..a8c57e8f301 100644 --- a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go +++ b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go @@ -18,6 +18,7 @@ package mock_services import ( "context" + "errors" "testing" "go.uber.org/mock/gomock" @@ -62,24 +63,25 @@ type ForkChoiceStorageMock struct { IsRootOptimisticVal bool - StateAtBlockRootVal map[common.Hash]*state.CachingBeaconState - StateAtSlotVal map[uint64]*state.CachingBeaconState - GetSyncCommitteesVal map[uint64][2]*solid.SyncCommittee - GetFinalityCheckpointsVal map[common.Hash][3]solid.Checkpoint - PendingConsolidationsVal map[common.Hash]*solid.ListSSZ[*solid.PendingConsolidation] - PendingDepositsVal map[common.Hash]*solid.ListSSZ[*solid.PendingDeposit] - PendingPartialWithdrawalsVal map[common.Hash]*solid.ListSSZ[*solid.PendingPartialWithdrawal] - WeightsMock []forkchoice.ForkNode - LightClientBootstraps map[common.Hash]*cltypes.LightClientBootstrap - NewestLCUpdate *cltypes.LightClientUpdate - LCUpdates map[uint64]*cltypes.LightClientUpdate - SyncContributionPool sync_contribution_pool.SyncContributionPool - Headers map[common.Hash]*cltypes.BeaconBlockHeader - Blocks map[common.Hash]*cltypes.SignedBeaconBlock - Envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope - VerifiedPayloads map[common.Hash]bool - OnExecutionPayloadErr error - GetBeaconCommitteeMock func(slot, committeeIndex uint64) ([]uint64, error) + StateAtBlockRootVal map[common.Hash]*state.CachingBeaconState + StateAtSlotVal map[uint64]*state.CachingBeaconState + GetSyncCommitteesVal map[uint64][2]*solid.SyncCommittee + GetFinalityCheckpointsVal map[common.Hash][3]solid.Checkpoint + PendingConsolidationsVal map[common.Hash]*solid.ListSSZ[*solid.PendingConsolidation] + PendingDepositsVal map[common.Hash]*solid.ListSSZ[*solid.PendingDeposit] + PendingPartialWithdrawalsVal map[common.Hash]*solid.ListSSZ[*solid.PendingPartialWithdrawal] + WeightsMock []forkchoice.ForkNode + LightClientBootstraps map[common.Hash]*cltypes.LightClientBootstrap + NewestLCUpdate *cltypes.LightClientUpdate + LCUpdates map[uint64]*cltypes.LightClientUpdate + SyncContributionPool sync_contribution_pool.SyncContributionPool + Headers map[common.Hash]*cltypes.BeaconBlockHeader + Blocks map[common.Hash]*cltypes.SignedBeaconBlock + Envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope + VerifiedPayloads map[common.Hash]bool + OnExecutionPayloadErr error + ValidateExecutionPayloadEnvelopeErr error + GetBeaconCommitteeMock func(slot, committeeIndex uint64) ([]uint64, error) Pool pool.OperationsPool @@ -356,10 +358,33 @@ func (f *ForkChoiceStorageMock) OnBlock( return nil } +func (f *ForkChoiceStorageMock) OnBlockWithEquivocationCheck( + ctx context.Context, + block *cltypes.SignedBeaconBlock, + newPayload bool, + fullValidation bool, + checkDataAvaiability bool, +) error { + if block != nil && block.Block != nil { + root, err := block.Block.HashSSZ() + if err != nil { + return err + } + if f.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, root) { + return errors.New("block conflicts with a previously validated proposal") + } + } + return f.OnBlock(ctx, block, newPayload, fullValidation, checkDataAvaiability) +} + func (f *ForkChoiceStorageMock) OnExecutionPayload(ctx context.Context, signedEnvelope *cltypes.SignedExecutionPayloadEnvelope, checkBlobData, validatePayload bool) error { return f.OnExecutionPayloadErr } +func (f *ForkChoiceStorageMock) ValidateExecutionPayloadEnvelope(signedEnvelope *cltypes.SignedExecutionPayloadEnvelope) error { + return f.ValidateExecutionPayloadEnvelopeErr +} + func (f *ForkChoiceStorageMock) ApplyLocalSelfBuildEnvelope(ctx context.Context, signedEnvelope *cltypes.SignedExecutionPayloadEnvelope) error { return nil } @@ -416,6 +441,15 @@ func (f *ForkChoiceStorageMock) HasBlockChildAtOrAfter(blockRoot common.Hash, sl return false } +func (f *ForkChoiceStorageMock) HasBlockEquivocation(slot, proposerIndex uint64, exceptRoot common.Hash) bool { + for root, block := range f.Blocks { + if root != exceptRoot && block != nil && block.Block != nil && block.Block.Slot == slot && block.Block.ProposerIndex == proposerIndex { + return true + } + } + return false +} + func (f *ForkChoiceStorageMock) Synced() bool { return true } diff --git a/cl/phase1/forkchoice/on_block.go b/cl/phase1/forkchoice/on_block.go index 11603fa90da..505ed48412f 100644 --- a/cl/phase1/forkchoice/on_block.go +++ b/cl/phase1/forkchoice/on_block.go @@ -87,6 +87,14 @@ func collectOnBlockLatencyToUnixTime(ethClock eth_clock.EthereumClock, slot, cur } func (f *ForkChoiceStore) OnBlock(ctx context.Context, block *cltypes.SignedBeaconBlock, newPayload, fullValidation, checkDataAvaiability bool) error { + return f.onBlock(ctx, block, newPayload, fullValidation, checkDataAvaiability, false) +} + +func (f *ForkChoiceStore) OnBlockWithEquivocationCheck(ctx context.Context, block *cltypes.SignedBeaconBlock, newPayload, fullValidation, checkDataAvaiability bool) error { + return f.onBlock(ctx, block, newPayload, fullValidation, checkDataAvaiability, true) +} + +func (f *ForkChoiceStore) onBlock(ctx context.Context, block *cltypes.SignedBeaconBlock, newPayload, fullValidation, checkDataAvaiability, rejectEquivocation bool) error { f.mu.Lock() unlocked := false defer f.drainQueuedWork() @@ -105,6 +113,9 @@ func (f *ForkChoiceStore) OnBlock(ctx context.Context, block *cltypes.SignedBeac return nil } } + if rejectEquivocation && f.forkGraph.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, blockRoot) { + return errors.New("block conflicts with a previously validated proposal") + } f.headHash = common.Hash{} f.headPayloadStatus = cltypes.PayloadStatusPending // Use the store's current slot (set via OnTick) to validate the block is not from the future. diff --git a/cl/phase1/forkchoice/on_execution_payload.go b/cl/phase1/forkchoice/on_execution_payload.go index d85d1abe548..aff685d7a13 100644 --- a/cl/phase1/forkchoice/on_execution_payload.go +++ b/cl/phase1/forkchoice/on_execution_payload.go @@ -599,6 +599,27 @@ func (f *ForkChoiceStore) OnExecutionPayload(ctx context.Context, signedEnvelope return nil } +func (f *ForkChoiceStore) ValidateExecutionPayloadEnvelope(signedEnvelope *cltypes.SignedExecutionPayloadEnvelope) error { + if signedEnvelope == nil || signedEnvelope.Message == nil { + return errors.New("nil execution payload envelope") + } + blockRoot := common.Hash(signedEnvelope.Message.BeaconBlockRoot) + f.mu.Lock() + defer f.mu.Unlock() + blockState, err := f.forkGraph.GetState(blockRoot, false) + if err != nil { + return fmt.Errorf("failed to get block state: %w", err) + } + if blockState == nil { + return fmt.Errorf("block state not found for beacon_block_root %v", blockRoot) + } + block, ok := f.forkGraph.GetBlock(blockRoot) + if !ok || block == nil { + return fmt.Errorf("block not found for beacon_block_root %v", blockRoot) + } + return f.validateEnvelopeAgainstBlock(signedEnvelope, block, blockState) +} + // ApplyLocalSelfBuildEnvelope processes a locally-produced self-build envelope // that carries InfiniteSignature. The CL node constructs these when the VC does // not provide a pre-signed envelope; the private key lives in the VC and is not diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index 990a11a95ff..85c5209ccc6 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -116,20 +116,10 @@ func (b *blockService) DecodeGossipMessage(_ peer.ID, data []byte, version clpar // ProcessMessage processes a block message according to https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#beacon_block func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltypes.SignedBeaconBlock) error { - log.Trace("Received block via gossip", "slot", msg.Block.Slot) - blockEpoch := msg.Block.Slot / b.beaconCfg.SlotsPerEpoch - - if b.syncedData.Syncing() { - return fmt.Errorf("%w: syncing", ErrIgnore) - } - - currentSlot := b.syncedData.HeadSlot() - - // [IGNORE] The block is not from a future slot (with a MAXIMUM_GOSSIP_CLOCK_DISPARITY allowance) -- i.e. validate that - // signed_beacon_block.message.slot <= current_slot (a client MAY queue future blocks for processing at the appropriate slot). - if currentSlot < msg.Block.Slot && !b.ethClock.IsSlotCurrentSlotWithMaximumClockDisparity(msg.Block.Slot) { - return fmt.Errorf("%w: block is not from a future slot: %d > %d", ErrIgnore, currentSlot, msg.Block.Slot) + if msg == nil || msg.Block == nil || msg.Block.Body == nil { + return errors.New("missing beacon block") } + log.Trace("Received block via gossip", "slot", msg.Block.Slot) // [IGNORE] The block is the first block with valid signature received for the proposer for the slot, signed_beacon_block.message.slot. seenCacheKey := proposerIndexAndSlot{ @@ -139,6 +129,37 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp if b.seenBlocksCache.Contains(seenCacheKey) { return nil } + if err := b.validateGossip(ctx, msg, func() { b.scheduleBlockForLaterProcessing(msg) }); err != nil { + return err + } + b.publishBlockGossipEvent(msg) + // the rest of the validation is done in the forkchoice store + if err := b.processAndStoreBlock(ctx, msg); err != nil { + if errors.Is(err, forkchoice.ErrEIP4844DataNotAvailable) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) || errors.Is(err, forkchoice.ErrParentEnvelopePending) { + b.scheduleBlockForLaterProcessing(msg) + return nil + } + return err + } + return nil +} + +func (b *blockService) ValidateGossip(ctx context.Context, msg *cltypes.SignedBeaconBlock) error { + return b.validateGossip(ctx, msg, nil) +} + +func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeaconBlock, schedule func()) error { + if msg == nil || msg.Block == nil || msg.Block.Body == nil { + return errors.New("missing beacon block") + } + if b.syncedData.Syncing() { + return fmt.Errorf("%w: syncing", ErrIgnore) + } + currentSlot := b.syncedData.HeadSlot() + if currentSlot < msg.Block.Slot && !b.ethClock.IsSlotCurrentSlotWithMaximumClockDisparity(msg.Block.Slot) { + return fmt.Errorf("%w: block is not from a future slot: %d > %d", ErrIgnore, currentSlot, msg.Block.Slot) + } + blockEpoch := msg.Block.Slot / b.beaconCfg.SlotsPerEpoch if err := b.syncedData.ViewHeadState(func(headState *state.CachingBeaconState) error { // [IGNORE] The block is from a slot greater than the latest finalized slot -- i.e. validate that signed_beacon_block.message.slot > compute_start_slot_at_epoch(store.finalized_checkpoint.epoch) @@ -154,8 +175,8 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp } return nil }); err != nil { - if errors.Is(err, ErrIgnore) { - b.scheduleBlockForLaterProcessing(msg) + if errors.Is(err, ErrIgnore) && schedule != nil { + schedule() } return err } @@ -163,7 +184,9 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp // [IGNORE] The block's parent (defined by block.parent_root) has been seen (via both gossip and non-gossip sources) (a client MAY queue blocks for processing once the parent block is retrieved). parentHeader, ok := b.forkchoiceStore.GetHeader(msg.Block.ParentRoot) if !ok { - b.scheduleBlockForLaterProcessing(msg) + if schedule != nil { + schedule() + } return fmt.Errorf("%w: parent header not found: %v", ErrIgnore, msg.Block.ParentRoot) } if parentHeader.Slot >= msg.Block.Slot { @@ -205,8 +228,9 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp parentBlockHash := bid.Message.ParentBlockHash status, seen := b.forkchoiceStore.GetRecentExecutionPayloadStatus(parentBlockHash) if !seen { - // Parent execution payload not seen yet, queue for later - b.scheduleBlockForLaterProcessing(msg) + if schedule != nil { + schedule() + } return fmt.Errorf("%w: parent execution payload not seen: %v", ErrIgnore, parentBlockHash) } if status == execution_client.PayloadStatusInvalidated { @@ -217,15 +241,6 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp // i.e. validate that len(body.signed_beacon_block.message.blob_kzg_commitments) <= MAX_BLOBS_PER_BLOCK return ErrInvalidCommitmentsCount } - b.publishBlockGossipEvent(msg) - // the rest of the validation is done in the forkchoice store - if err := b.processAndStoreBlock(ctx, msg); err != nil { - if errors.Is(err, forkchoice.ErrEIP4844DataNotAvailable) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) || errors.Is(err, forkchoice.ErrParentEnvelopePending) { - b.scheduleBlockForLaterProcessing(msg) - return nil - } - return err - } return nil } diff --git a/cl/phase1/network/services/execution_payload_service.go b/cl/phase1/network/services/execution_payload_service.go index 23a86904b43..fdfd2457bb2 100644 --- a/cl/phase1/network/services/execution_payload_service.go +++ b/cl/phase1/network/services/execution_payload_service.go @@ -180,6 +180,9 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64, // Process the execution payload through forkchoice // Note: bid matching and signature verification are done in OnExecutionPayload.validateEnvelopeAgainstBlock if err := s.forkchoiceStore.OnExecutionPayload(ctx, signedEnvelope, true, true); err != nil { + if errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) { + s.emitExecutionPayloadGossip(block, envelope) + } if errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("%w: %v", ErrIgnore, err) //nolint:errorlint // converting, not wrapping: the forkchoice sentinels must not stay matchable @@ -191,10 +194,7 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64, // This ensures invalid envelopes (e.g., with forged signatures) don't block valid ones s.seenEnvelopesCache.Add(seenKey, struct{}{}) - payloadEvent := &beaconevents.ExecutionPayloadGossipData{ - Slot: block.Block.Slot, BuilderIndex: builderIndex, BlockHash: envelope.Payload.BlockHash, BlockRoot: beaconBlockRoot, - } - s.emitters.Operation().SendExecutionPayloadGossip(payloadEvent) + s.emitExecutionPayloadGossip(block, envelope) s.emitters.Operation().SendExecutionPayload(&beaconevents.ExecutionPayloadData{ Slot: block.Block.Slot, BuilderIndex: builderIndex, BlockHash: envelope.Payload.BlockHash, BlockRoot: beaconBlockRoot, ExecutionOptimistic: s.forkchoiceStore.IsRootOptimistic(beaconBlockRoot), @@ -215,6 +215,15 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64, return nil } +func (s *executionPayloadService) emitExecutionPayloadGossip(block *cltypes.SignedBeaconBlock, envelope *cltypes.ExecutionPayloadEnvelope) { + s.emitters.Operation().SendExecutionPayloadGossip(&beaconevents.ExecutionPayloadGossipData{ + Slot: block.Block.Slot, + BuilderIndex: envelope.BuilderIndex, + BlockHash: envelope.Payload.BlockHash, + BlockRoot: envelope.BeaconBlockRoot, + }) +} + func (s *executionPayloadService) emitFullHeadUpdate(block *cltypes.SignedBeaconBlock, blockRoot common.Hash) { headRoot, headSlot, err := s.forkchoiceStore.GetHead(nil) if err != nil || headRoot != blockRoot || s.beaconCfg.SlotsPerEpoch == 0 { @@ -224,29 +233,23 @@ func (s *executionPayloadService) emitFullHeadUpdate(block *cltypes.SignedBeacon if err != nil || headState == nil { return } - epoch := headSlot / s.beaconCfg.SlotsPerEpoch - currentDependentRoot := s.forkchoiceStore.AnchorRoot() - nextDependentRoot := s.forkchoiceStore.AnchorRoot() - if epoch > 1 { - currentDependentRoot, err = headState.GetBlockRootAtSlot((epoch-1)*s.beaconCfg.SlotsPerEpoch - 1) - if err != nil { - return - } + headEvent, err := beaconevents.BuildHeadV2Data( + s.beaconCfg, + headState, + headSlot, + headRoot, + block.Block.StateRoot, + "full", + s.forkchoiceStore.IsRootOptimistic(blockRoot), + ) + if err != nil { + return } - if epoch > 0 { - nextDependentRoot, err = headState.GetBlockRootAtSlot(epoch*s.beaconCfg.SlotsPerEpoch - 1) - if err != nil { - return - } + currentHeadRoot, currentHeadSlot, err := s.forkchoiceStore.GetHead(nil) + if err != nil || currentHeadRoot != headRoot || currentHeadSlot != headSlot { + return } - s.emitters.State().SendHeadV2(&beaconevents.HeadV2Data{ - Version: clparams.GloasVersion.String(), - Data: beaconevents.HeadV2Content{ - Slot: headSlot, Block: headRoot, State: block.Block.StateRoot, PayloadStatus: "full", - EpochTransition: headSlot%s.beaconCfg.SlotsPerEpoch == 0, CurrentEpochDependentRoot: currentDependentRoot, - NextEpochDependentRoot: nextDependentRoot, ExecutionOptimistic: s.forkchoiceStore.IsRootOptimistic(blockRoot), - }, - }) + s.emitters.State().SendHeadV2(headEvent) } // queuePendingEnvelope adds an envelope to the pending queue for later processing diff --git a/cl/phase1/network/services/execution_payload_service_test.go b/cl/phase1/network/services/execution_payload_service_test.go index da54f5c2330..bd2cd52d603 100644 --- a/cl/phase1/network/services/execution_payload_service_test.go +++ b/cl/phase1/network/services/execution_payload_service_test.go @@ -31,6 +31,7 @@ import ( "github.com/erigontech/erigon/cl/cltypes/solid" "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/phase1/core/state/lru" + "github.com/erigontech/erigon/cl/phase1/forkchoice" "github.com/erigontech/erigon/cl/phase1/forkchoice/mock_services" "github.com/erigontech/erigon/common" ) @@ -138,6 +139,123 @@ func TestExecutionPayloadServiceEmitsGossipAndImportedEvents(t *testing.T) { require.Equal(t, blockRoot, headEvent.Data.(*beaconevents.HeadV2Data).Data.Block) } +func TestExecutionPayloadServiceEmitsGossipWhenValidatedEnvelopeWaitsForColumns(t *testing.T) { + cfg := &clparams.MainnetBeaconConfig + forkchoiceMock := mock_services.NewForkChoiceStorageMock(t) + emitter := beaconevents.NewEventEmitter() + service := NewExecutionPayloadService(t.Context(), forkchoiceMock, cfg, emitter) + events := make(chan *beaconevents.EventStream, 1) + subscription := emitter.Operation().Subscribe(events) + defer subscription.Unsubscribe() + + blockRoot := common.Hash{1} + forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100}} + forkchoiceMock.OnExecutionPayloadErr = forkchoice.ErrEIP7594ColumnDataNotAvailable + envelope := newTestSignedEnvelope(100, blockRoot, 7) + + require.ErrorIs(t, service.ProcessMessage(t.Context(), nil, envelope), ErrIgnore) + select { + case event := <-events: + require.Equal(t, beaconevents.OpExecutionPayloadGossip, event.Event) + require.Equal(t, blockRoot, event.Data.(*beaconevents.ExecutionPayloadGossipData).BlockRoot) + default: + t.Fatal("validated gossip envelope did not emit execution_payload_gossip while waiting for columns") + } +} + +func TestExecutionPayloadServiceDoesNotEmitStaleHeadV2AfterReorg(t *testing.T) { + cfg := &clparams.MainnetBeaconConfig + forkchoiceMock := mock_services.NewForkChoiceStorageMock(t) + emitter := beaconevents.NewEventEmitter() + service := NewExecutionPayloadService(t.Context(), forkchoiceMock, cfg, emitter) + stateEvents := make(chan *beaconevents.EventStream, 1) + stateSubscription := emitter.State().Subscribe(stateEvents) + defer stateSubscription.Unsubscribe() + + blockRoot := common.Hash{1} + reorgRoot := common.Hash{9} + forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100, StateRoot: common.Hash{2}}} + headState := state.New(cfg) + headState.SetVersion(clparams.GloasVersion) + headState.SetSlot(100) + headState.SetBlockRootAt(63, common.Hash{3}) + headState.SetBlockRootAt(95, common.Hash{4}) + forkchoiceMock.GetStateAtBlockRootFn = func(root common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) { + require.Equal(t, blockRoot, root) + require.True(t, alwaysCopy) + forkchoiceMock.HeadVal = reorgRoot + return headState, nil + } + forkchoiceMock.HeadVal = blockRoot + forkchoiceMock.HeadSlotVal = 100 + + require.NoError(t, service.ProcessMessage(t.Context(), nil, newTestSignedEnvelope(100, blockRoot, 7))) + select { + case event := <-stateEvents: + t.Fatalf("emitted stale event after reorg: %#v", event) + default: + } +} + +func TestExecutionPayloadServiceDoesNotEmitGossipWhenValidationFails(t *testing.T) { + cfg := &clparams.MainnetBeaconConfig + forkchoiceMock := mock_services.NewForkChoiceStorageMock(t) + emitter := beaconevents.NewEventEmitter() + service := NewExecutionPayloadService(t.Context(), forkchoiceMock, cfg, emitter) + events := make(chan *beaconevents.EventStream, 1) + subscription := emitter.Operation().Subscribe(events) + defer subscription.Unsubscribe() + + blockRoot := common.Hash{1} + forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100}} + forkchoiceMock.OnExecutionPayloadErr = errors.New("invalid envelope signature") + + require.Error(t, service.ProcessMessage(t.Context(), nil, newTestSignedEnvelope(100, blockRoot, 7))) + select { + case event := <-events: + t.Fatalf("emitted gossip event for invalid envelope: %#v", event) + default: + } +} + +func TestExecutionPayloadServiceProgressesWhileEventFeedIsBlocked(t *testing.T) { + cfg := &clparams.MainnetBeaconConfig + forkchoiceMock := mock_services.NewForkChoiceStorageMock(t) + emitter := beaconevents.NewEventEmitter() + service := NewExecutionPayloadService(t.Context(), forkchoiceMock, cfg, emitter) + slow := make(chan *beaconevents.EventStream) + slowSubscription := emitter.Operation().Subscribe(slow) + defer slowSubscription.Unsubscribe() + ready := make(chan *beaconevents.EventStream) + readySubscription := emitter.Operation().Subscribe(ready) + defer readySubscription.Unsubscribe() + blockedSendDone := make(chan struct{}) + go func() { + emitter.Operation().SendAttestation(&beaconevents.AttestationData{}) + close(blockedSendDone) + }() + <-ready + + blockRoot := common.Hash{1} + forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100}} + processDone := make(chan error, 1) + ctx := t.Context() + go func() { processDone <- service.ProcessMessage(ctx, nil, newTestSignedEnvelope(100, blockRoot, 7)) }() + select { + case err := <-processDone: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("execution payload gossip processing blocked on the event feed") + } + + slowSubscription.Unsubscribe() + select { + case <-blockedSendDone: + case <-time.After(time.Second): + t.Fatal("legacy event send remained blocked after unsubscribe") + } +} + func TestExecutionPayloadServiceAlreadySeen(t *testing.T) { service, fcu := setupExecutionPayloadService(t) diff --git a/cl/phase1/network/services/mock_services/block_service_mock.go b/cl/phase1/network/services/mock_services/block_service_mock.go index 30c1dff006f..f55b32dc9ef 100644 --- a/cl/phase1/network/services/mock_services/block_service_mock.go +++ b/cl/phase1/network/services/mock_services/block_service_mock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/erigontech/erigon/cl/phase1/network/services (interfaces: BlockService) +// Source: ./cl/phase1/network/services (interfaces: BlockService) // // Generated by this command: // -// mockgen -typed=true -destination=./mock_services/block_service_mock.go -package=mock_services . BlockService +// mockgen -typed=true -destination=./cl/phase1/network/services/mock_services/block_service_mock.go -package=mock_services ./cl/phase1/network/services BlockService // // Package mock_services is a generated GoMock package. @@ -157,3 +157,41 @@ func (c *MockBlockServiceProcessMessageCall) DoAndReturn(f func(context.Context, c.Call = c.Call.DoAndReturn(f) return c } + +// ValidateGossip mocks base method. +func (m *MockBlockService) ValidateGossip(arg0 context.Context, arg1 *cltypes.SignedBeaconBlock) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ValidateGossip", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// ValidateGossip indicates an expected call of ValidateGossip. +func (mr *MockBlockServiceMockRecorder) ValidateGossip(arg0, arg1 any) *MockBlockServiceValidateGossipCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateGossip", reflect.TypeOf((*MockBlockService)(nil).ValidateGossip), arg0, arg1) + return &MockBlockServiceValidateGossipCall{Call: call} +} + +// MockBlockServiceValidateGossipCall wrap *gomock.Call +type MockBlockServiceValidateGossipCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockBlockServiceValidateGossipCall) Return(arg0 error) *MockBlockServiceValidateGossipCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockBlockServiceValidateGossipCall) Do(f func(context.Context, *cltypes.SignedBeaconBlock) error) *MockBlockServiceValidateGossipCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockBlockServiceValidateGossipCall) DoAndReturn(f func(context.Context, *cltypes.SignedBeaconBlock) error) *MockBlockServiceValidateGossipCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cl/phase1/network/services/proposer_preferences_service.go b/cl/phase1/network/services/proposer_preferences_service.go index 25b17a49ca7..d133bb37222 100644 --- a/cl/phase1/network/services/proposer_preferences_service.go +++ b/cl/phase1/network/services/proposer_preferences_service.go @@ -149,8 +149,8 @@ func (s *proposerPreferencesService) ProcessMessage(ctx context.Context, _ *uint } s.storeMu.Lock() - defer s.storeMu.Unlock() if s.hasSeenPreference(seenKey) { + s.storeMu.Unlock() return fmt.Errorf("%w: already seen proposer preferences from validator %d for slot %d with dependent root %v", ErrIgnore, validatorIndex, proposalSlot, preferences.DependentRoot) } @@ -158,6 +158,7 @@ func (s *proposerPreferencesService) ProcessMessage(ctx context.Context, _ *uint Slot: proposalSlot, DependentRoot: preferences.DependentRoot, }, msg) + s.storeMu.Unlock() if s.emitters != nil { s.emitters.Operation().SendProposerPreferences(&beaconevents.VersionedSignedProposerPreferences{Version: clparams.GloasVersion.String(), Data: msg}) } diff --git a/cl/phase1/network/services/proposer_preferences_service_test.go b/cl/phase1/network/services/proposer_preferences_service_test.go index 0a56332f81b..98275da59fb 100644 --- a/cl/phase1/network/services/proposer_preferences_service_test.go +++ b/cl/phase1/network/services/proposer_preferences_service_test.go @@ -281,6 +281,47 @@ func TestProposerPreferencesServiceEmitsEvent(t *testing.T) { require.Equal(t, &beaconevents.VersionedSignedProposerPreferences{Version: "gloas", Data: msg}, event.Data) } +func TestProposerPreferencesServiceProgressesWhileEventFeedIsBlocked(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, _, epbsPool, _ := setupProposerPreferencesService(t, ctrl) + emitter := beaconevents.NewEventEmitter() + service.emitters = emitter + slow := make(chan *beaconevents.EventStream) + slowSubscription := emitter.Operation().Subscribe(slow) + defer slowSubscription.Unsubscribe() + ready := make(chan *beaconevents.EventStream) + readySubscription := emitter.Operation().Subscribe(ready) + defer readySubscription.Unsubscribe() + blockedSendDone := make(chan struct{}) + go func() { + emitter.Operation().SendAttestation(&beaconevents.AttestationData{}) + close(blockedSendDone) + }() + <-ready + + msg := newTestSignedProposerPreferences(96, 42) + service.now = func() time.Time { return service.ethClock.GetSlotTime(96).Add(gloasMaximumClockDisparity) } + processDone := make(chan error, 1) + ctx := t.Context() + go func() { processDone <- service.ProcessMessage(ctx, nil, msg) }() + select { + case err := <-processDone: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("proposer preferences processing blocked on the event feed") + } + stored, ok := epbsPool.GetPreference(96, testDependentRoot) + require.True(t, ok) + require.Same(t, msg, stored) + + slowSubscription.Unsubscribe() + select { + case <-blockedSendDone: + case <-time.After(time.Second): + t.Fatal("legacy event send remained blocked after unsubscribe") + } +} + func TestProposerPreferencesServiceLookaheadClockDisparityBoundary(t *testing.T) { ctrl := gomock.NewController(t) service, _, _, epbsPool, _ := setupProposerPreferencesService(t, ctrl) diff --git a/cl/phase1/network/services/types.go b/cl/phase1/network/services/types.go index 1a83733d789..5b0f20c1199 100644 --- a/cl/phase1/network/services/types.go +++ b/cl/phase1/network/services/types.go @@ -1,12 +1,17 @@ package services import ( + "context" + "github.com/erigontech/erigon/cl/cltypes" serviceinterface "github.com/erigontech/erigon/cl/phase1/network/services/service_interface" ) //go:generate mockgen -typed=true -destination=./mock_services/block_service_mock.go -package=mock_services . BlockService -type BlockService serviceinterface.Service[*cltypes.SignedBeaconBlock] +type BlockService interface { + serviceinterface.Service[*cltypes.SignedBeaconBlock] + ValidateGossip(context.Context, *cltypes.SignedBeaconBlock) error +} //go:generate mockgen -typed=true -destination=./mock_services/blob_sidecars_service_mock.go -package=mock_services . BlobSidecarsService type BlobSidecarsService serviceinterface.Service[*cltypes.BlobSidecar] diff --git a/cl/phase1/stages/forkchoice.go b/cl/phase1/stages/forkchoice.go index 6312074bf9a..7f481a09389 100644 --- a/cl/phase1/stages/forkchoice.go +++ b/cl/phase1/stages/forkchoice.go @@ -216,49 +216,40 @@ func updateCanonicalChainInTheDatabase(ctx context.Context, tx kv.RwTx, headSlot // emitHeadEvent emits the head event with the given head slot, head root, and head state. func emitHeadEvent(cfg *Cfg, headSlot uint64, headRoot common.Hash, headState *state.CachingBeaconState) error { - headEpoch := headSlot / cfg.beaconCfg.SlotsPerEpoch - previous_duty_dependent_root := cfg.forkChoice.AnchorRoot() - current_duty_dependent_root := cfg.forkChoice.AnchorRoot() - var err error - if headEpoch > 1 { - previous_duty_dependent_root, err = headState.GetBlockRootAtSlot((headEpoch-1)*cfg.beaconCfg.SlotsPerEpoch - 1) - if err != nil { - return fmt.Errorf("failed to get block root at slot for previous_duty_dependent_root: %w", err) - } - } - if headEpoch > 0 { - current_duty_dependent_root, err = headState.GetBlockRootAtSlot(headEpoch*cfg.beaconCfg.SlotsPerEpoch - 1) - if err != nil { - return fmt.Errorf("failed to get block root at slot for current_duty_dependent_root: %w", err) - } - } - stateRoot, err := headState.HashSSZ() if err != nil { return fmt.Errorf("failed to hash ssz: %w", err) } + currentHeadRoot, currentHeadSlot, err := cfg.forkChoice.GetHead(nil) + if err != nil { + return fmt.Errorf("failed to revalidate head event: %w", err) + } + if currentHeadRoot != headRoot || currentHeadSlot != headSlot { + return nil + } + headEvent, err := beaconevents.BuildHeadV2Data( + cfg.beaconCfg, + headState, + headSlot, + headRoot, + stateRoot, + beaconevents.PayloadStatusName(cfg.forkChoice.GetHeadPayloadStatus()), + cfg.forkChoice.IsHeadOptimistic(), + ) + if err != nil { + return err + } // emit the head event cfg.emitter.State().SendHead(&beaconevents.HeadData{ Slot: headSlot, Block: headRoot, State: stateRoot, EpochTransition: true, - PreviousDutyDependentRoot: previous_duty_dependent_root, - CurrentDutyDependentRoot: current_duty_dependent_root, + PreviousDutyDependentRoot: headEvent.Data.CurrentEpochDependentRoot, + CurrentDutyDependentRoot: headEvent.Data.NextEpochDependentRoot, ExecutionOptimistic: false, }) - currentEpochDependentRoot, nextEpochDependentRoot := previous_duty_dependent_root, current_duty_dependent_root - cfg.emitter.State().SendHeadV2(&beaconevents.HeadV2Data{ - Version: headState.Version().String(), - Data: beaconevents.HeadV2Content{ - Slot: headSlot, Block: headRoot, State: stateRoot, - PayloadStatus: beaconevents.PayloadStatusName(cfg.forkChoice.GetHeadPayloadStatus()), - EpochTransition: cfg.beaconCfg.SlotsPerEpoch != 0 && headSlot%cfg.beaconCfg.SlotsPerEpoch == 0, - CurrentEpochDependentRoot: currentEpochDependentRoot, - NextEpochDependentRoot: nextEpochDependentRoot, - ExecutionOptimistic: cfg.forkChoice.IsHeadOptimistic(), - }, - }) + cfg.emitter.State().SendHeadV2(headEvent) return nil } diff --git a/cmd/caplin/caplin1/option.go b/cmd/caplin/caplin1/option.go index c01ecf55afc..9f2d519e213 100644 --- a/cmd/caplin/caplin1/option.go +++ b/cmd/caplin/caplin1/option.go @@ -27,8 +27,29 @@ type option struct { type CaplinOption func(*option) -func WithBuilder(mevRelayUrl string, beaconConfig *clparams.BeaconChainConfig) CaplinOption { +func WithBuilder(mevRelayURL string, beaconConfig *clparams.BeaconChainConfig, policy builder.BuilderTargetPolicy) CaplinOption { return func(o *option) { - o.builderClient = builder.NewBlockBuilderClient(mevRelayUrl, beaconConfig) + if mevRelayURL == "" { + o.builderClient = builder.NewDynamicBuilderClient(beaconConfig, policy) + return + } + o.builderClient = builder.NewBlockBuilderClientWithPolicy(mevRelayURL, beaconConfig, policy) } } + +func builderOptionForConfig(config *clparams.CaplinConfig, beaconConfig *clparams.BeaconChainConfig) (CaplinOption, bool) { + legacyRelayURL := "" + skippedLegacy := false + if config.BeaconAPIRouter.Builder { + if config.RelayUrlExist() { + legacyRelayURL = config.MevRelayUrl + } else { + config.BeaconAPIRouter.Builder = false + skippedLegacy = true + } + } + if !config.BeaconAPIRouter.Validator && !config.BeaconAPIRouter.Builder { + return nil, skippedLegacy + } + return WithBuilder(legacyRelayURL, beaconConfig, builder.BuilderTargetPolicy{}), skippedLegacy +} diff --git a/cmd/caplin/caplin1/option_test.go b/cmd/caplin/caplin1/option_test.go new file mode 100644 index 00000000000..ab432c7defb --- /dev/null +++ b/cmd/caplin/caplin1/option_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package caplin1 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cl/beacon/builder" + "github.com/erigontech/erigon/cl/clparams" +) + +func TestWithBuilderSupportsDynamicValidatorAPIWithoutRelay(t *testing.T) { + var got option + WithBuilder("", &clparams.MainnetBeaconConfig, builder.BuilderTargetPolicy{})(&got) + require.NotNil(t, got.builderClient) +} + +func TestBuilderOptionKeepsDynamicAndLegacyGatingIndependent(t *testing.T) { + t.Run("validator API without relay", func(t *testing.T) { + config := &clparams.CaplinConfig{} + config.BeaconAPIRouter.Validator = true + builderOption, skippedLegacy := builderOptionForConfig(config, &clparams.MainnetBeaconConfig) + require.NotNil(t, builderOption) + require.False(t, skippedLegacy) + var got option + builderOption(&got) + require.NotNil(t, got.builderClient) + }) + + t.Run("legacy builder API without relay", func(t *testing.T) { + config := &clparams.CaplinConfig{} + config.BeaconAPIRouter.Builder = true + builderOption, skippedLegacy := builderOptionForConfig(config, &clparams.MainnetBeaconConfig) + require.Nil(t, builderOption) + require.True(t, skippedLegacy) + require.False(t, config.BeaconAPIRouter.Builder) + }) +} diff --git a/cmd/caplin/caplin1/run.go b/cmd/caplin/caplin1/run.go index 4b7aaa31e6a..36c81bdbfda 100644 --- a/cmd/caplin/caplin1/run.go +++ b/cmd/caplin/caplin1/run.go @@ -303,13 +303,13 @@ func RunCaplinService(ctx context.Context, engine execution_client.ExecutionEngi } caplinOptions := []CaplinOption{} - if config.BeaconAPIRouter.Builder { - if config.RelayUrlExist() { - caplinOptions = append(caplinOptions, WithBuilder(config.MevRelayUrl, beaconConfig)) - } else { + if builderOption, skippedLegacy := builderOptionForConfig(&config, beaconConfig); builderOption != nil { + caplinOptions = append(caplinOptions, builderOption) + if skippedLegacy { log.Warn("builder api enable but relay url not set. Skipping builder mode") - config.BeaconAPIRouter.Builder = false } + } else if skippedLegacy { + log.Warn("builder api enable but relay url not set. Skipping builder mode") } log.Info("Starting caplin") @@ -617,6 +617,7 @@ func RunCaplinService(ctx context.Context, engine execution_client.ExecutionEngi voluntaryExitService, blsToExecutionChangeService, proposerSlashingService, + blockService, option.builderClient, stateSnapshots, gossipManager, diff --git a/common/event/feed.go b/common/event/feed.go index 3de8f053e41..ad4e6305adf 100644 --- a/common/event/feed.go +++ b/common/event/feed.go @@ -182,6 +182,37 @@ func (f *Feed) Send(value any) (nsent int) { return nsent } +// TrySend delivers to subscribers that can receive immediately and drops the value for the rest. +func (f *Feed) TrySend(value any) (nsent int) { + rvalue := reflect.ValueOf(value) + + f.once.Do(func() { f.init(rvalue.Type()) }) + if f.etype != rvalue.Type() { + panic(feedTypeError{op: "TrySend", got: rvalue.Type(), want: f.etype}) + } + + select { + case <-f.sendLock: + default: + return 0 + } + defer func() { f.sendLock <- struct{}{} }() + + if !f.mu.TryLock() { + return 0 + } + f.sendCases = append(f.sendCases, f.inbox...) + f.inbox = nil + f.mu.Unlock() + + for i := firstSubSendCase; i < len(f.sendCases); i++ { + if f.sendCases[i].Chan.TrySend(rvalue) { + nsent++ + } + } + return nsent +} + type feedSub struct { feed *Feed channel reflect.Value diff --git a/common/event/feed_test.go b/common/event/feed_test.go index 92a4bfa3cbb..c0668c738e9 100644 --- a/common/event/feed_test.go +++ b/common/event/feed_test.go @@ -66,6 +66,14 @@ func TestFeedPanics(t *testing.T) { t.Error(err) } } + { + var f Feed + f.TrySend(2) + want := feedTypeError{op: "TrySend", got: reflect.TypeFor[uint64](), want: reflect.TypeFor[int]()} + if err := checkPanic(want, func() { f.TrySend(uint64(2)) }); err != nil { + t.Error(err) + } + } } func checkPanic(want error, fn func()) (err error) { @@ -129,6 +137,79 @@ func TestFeed(t *testing.T) { done.Wait() } +func TestFeedTrySendSkipsSlowSubscribers(t *testing.T) { + var feed Feed + slow := make(chan int) + slowSub := feed.Subscribe(slow) + defer slowSub.Unsubscribe() + ready := make(chan int, 1) + readySub := feed.Subscribe(ready) + defer readySub.Unsubscribe() + + if delivered := feed.TrySend(1); delivered != 1 { + t.Fatalf("TrySend delivered %d times, want 1", delivered) + } + if value := <-ready; value != 1 { + t.Fatalf("received %d, want 1", value) + } +} + +func TestFeedTrySendDoesNotWaitForBlockedSend(t *testing.T) { + var feed Feed + slow := make(chan int) + slowSub := feed.Subscribe(slow) + defer slowSub.Unsubscribe() + ready := make(chan int) + readySub := feed.Subscribe(ready) + defer readySub.Unsubscribe() + + sendDone := make(chan struct{}) + go func() { + feed.Send(1) + close(sendDone) + }() + if value := <-ready; value != 1 { + t.Fatalf("received %d, want 1", value) + } + + completed := make(chan int, 1) + go func() { completed <- feed.TrySend(2) }() + select { + case delivered := <-completed: + if delivered != 0 { + t.Fatalf("TrySend delivered %d times, want 0", delivered) + } + case <-time.After(time.Second): + t.Fatal("TrySend waited for a blocked Send") + } + + slowSub.Unsubscribe() + select { + case <-sendDone: + case <-time.After(time.Second): + t.Fatal("Send remained blocked after unsubscribe") + } +} + +func TestFeedTrySendDoesNotWaitForSubscriptionLock(t *testing.T) { + var feed Feed + sub := feed.Subscribe(make(chan int, 1)) + defer sub.Unsubscribe() + feed.mu.Lock() + defer feed.mu.Unlock() + + completed := make(chan int, 1) + go func() { completed <- feed.TrySend(1) }() + select { + case delivered := <-completed: + if delivered != 0 { + t.Fatalf("TrySend delivered %d times, want 0", delivered) + } + case <-time.After(time.Second): + t.Fatal("TrySend waited for the subscription lock") + } +} + func TestFeedSubscribeSameChannel(t *testing.T) { var ( feed Feed From 48307c4f53433f959903cf3d1ced81b5d31f55aa Mon Sep 17 00:00:00 2001 From: kewei Date: Tue, 25 Aug 2026 20:50:40 +0800 Subject: [PATCH 05/43] cl: harden Gloas API validation --- cl/beacon/beaconevents/gloas_events_test.go | 18 ++ cl/beacon/beaconevents/head_v2.go | 4 +- cl/beacon/builder/client.go | 29 ++- cl/beacon/builder/client_test.go | 73 ++++++++ cl/beacon/handler/block_production.go | 22 ++- cl/beacon/handler/block_production_test.go | 13 ++ cl/beacon/handler/epbs.go | 41 ++++- cl/beacon/handler/epbs_test.go | 105 ++++++++++- cl/phase1/network/services/block_service.go | 58 ++++-- .../network/services/block_service_test.go | 172 +++++++++++++++++- cl/phase1/stages/forkchoice.go | 18 +- cl/phase1/stages/forkchoice_test.go | 14 ++ 12 files changed, 530 insertions(+), 37 deletions(-) diff --git a/cl/beacon/beaconevents/gloas_events_test.go b/cl/beacon/beaconevents/gloas_events_test.go index f336cf1cd0f..7383060b071 100644 --- a/cl/beacon/beaconevents/gloas_events_test.go +++ b/cl/beacon/beaconevents/gloas_events_test.go @@ -63,6 +63,24 @@ func TestBuildHeadV2DataUsesGenesisRootInEpochZeroAndOne(t *testing.T) { } } +func TestBuildHeadV2DataDoesNotReadPrunedGenesisRoot(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + cfg.SlotsPerEpoch = 2 + cfg.SlotsPerHistoricalRoot = 8 + headState := state.New(&cfg) + headState.SetVersion(clparams.GloasVersion) + headState.SetSlot(10) + currentRoot := common.Hash{1} + nextRoot := common.Hash{2} + headState.SetBlockRootAt(7, currentRoot) + headState.SetBlockRootAt(1, nextRoot) + + event, err := BuildHeadV2Data(&cfg, headState, 10, common.Hash{3}, common.Hash{4}, "full", false) + require.NoError(t, err) + require.Equal(t, currentRoot, event.Data.CurrentEpochDependentRoot) + require.Equal(t, nextRoot, event.Data.NextEpochDependentRoot) +} + func TestGloasEventFeedsDoNotBlockOnSlowSubscriber(t *testing.T) { tests := []struct { name string diff --git a/cl/beacon/beaconevents/head_v2.go b/cl/beacon/beaconevents/head_v2.go index df4d0d5bcaa..215c1a2e77d 100644 --- a/cl/beacon/beaconevents/head_v2.go +++ b/cl/beacon/beaconevents/head_v2.go @@ -42,15 +42,15 @@ func BuildHeadV2Data( return nil, errors.New("nil head state") } + headEpoch := headSlot / beaconCfg.SlotsPerEpoch genesisRoot := headRoot var err error - if headSlot > 0 { + if headSlot > 0 && headEpoch <= 1 { genesisRoot, err = headState.GetBlockRootAtSlot(0) if err != nil { return nil, fmt.Errorf("get genesis block root: %w", err) } } - headEpoch := headSlot / beaconCfg.SlotsPerEpoch currentDependentRoot := genesisRoot nextDependentRoot := genesisRoot if headEpoch > 1 { diff --git a/cl/beacon/builder/client.go b/cl/beacon/builder/client.go index 10a78b058a0..069a7fc0247 100644 --- a/cl/beacon/builder/client.go +++ b/cl/beacon/builder/client.go @@ -357,7 +357,7 @@ func (b *builderClient) SubmitSignedBeaconBlock(ctx context.Context, builderURL type builderTarget struct { url string hostname string - ip net.IP + ips []net.IP } func (b *builderClient) builderEndpoint(ctx context.Context, policy BuilderTargetPolicy, rawURL string, path ...string) (builderTarget, error) { @@ -390,7 +390,11 @@ func (b *builderClient) builderEndpoint(ctx context.Context, policy BuilderTarge return builderTarget{}, fmt.Errorf("builder URL resolves to disallowed address %s", address.IP) } } - return builderTarget{url: target.JoinPath(path...).String(), hostname: hostname, ip: addresses[0].IP}, nil + validatedIPs := make([]net.IP, len(addresses)) + for i, address := range addresses { + validatedIPs[i] = append(net.IP(nil), address.IP...) + } + return builderTarget{url: target.JoinPath(path...).String(), hostname: hostname, ips: validatedIPs}, nil } func isPublicBuilderIP(ip net.IP) bool { @@ -411,7 +415,7 @@ type builderHTTPResponse struct { } func (b *builderClient) builderCall(ctx context.Context, method string, target builderTarget, headers map[string]string, body io.Reader) (*builderHTTPResponse, error) { - requestContext := context.WithValue(ctx, pinnedBuilderTargetKey{}, pinnedBuilderTarget{hostname: target.hostname, ip: target.ip}) + requestContext := context.WithValue(ctx, pinnedBuilderTargetKey{}, pinnedBuilderTarget{hostname: target.hostname, ips: target.ips}) request, err := http.NewRequestWithContext(requestContext, method, target.url, body) if err != nil { return nil, err @@ -466,7 +470,7 @@ type pinnedBuilderTargetKey struct{} type pinnedBuilderTarget struct { hostname string - ip net.IP + ips []net.IP } func newPinnedBuilderTransport(dialContext func(context.Context, string, string) (net.Conn, error)) http.RoundTripper { @@ -487,7 +491,22 @@ func newPinnedBuilderTransport(dialContext func(context.Context, string, string) if !strings.EqualFold(host, pinned.hostname) { return nil, errors.New("builder dial target does not match resolved host") } - return dialContext(ctx, network, net.JoinHostPort(pinned.ip.String(), port)) + var dialErrors []error + for _, ip := range pinned.ips { + if err := ctx.Err(); err != nil { + return nil, err + } + pinnedAddress := net.JoinHostPort(ip.String(), port) + conn, err := dialContext(ctx, network, pinnedAddress) + if err == nil { + return conn, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + dialErrors = append(dialErrors, fmt.Errorf("dial builder address %s: %w", pinnedAddress, err)) + } + return nil, errors.Join(dialErrors...) } return transport } diff --git a/cl/beacon/builder/client_test.go b/cl/beacon/builder/client_test.go index 41ad284a8c5..b8164c79bf9 100644 --- a/cl/beacon/builder/client_test.go +++ b/cl/beacon/builder/client_test.go @@ -22,6 +22,7 @@ import ( "context" _ "embed" "encoding/json" + "errors" "fmt" "io" "math" @@ -407,6 +408,12 @@ func TestBuilderTransportRejectsUnsafeTargets(t *testing.T) { } _, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second) require.Error(t, err) + + client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("10.0.0.1")}}, nil + } + _, err = client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second) + require.Error(t, err) } func TestRequestExecutionPayloadBidBoundsResponseAndRefusesRedirect(t *testing.T) { @@ -560,6 +567,72 @@ func TestDynamicBuilderDialUsesValidatedAddress(t *testing.T) { require.Equal(t, "93.184.216.34:18550", dialed) } +func TestDynamicBuilderDialFallsBackAcrossValidatedAddresses(t *testing.T) { + addresses := []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("93.184.216.35")}} + var lookups int + var dialed []string + client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{}) + client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { + lookups++ + return addresses, nil + } + client.transport = newPinnedBuilderTransport(func(_ context.Context, _, address string) (net.Conn, error) { + dialed = append(dialed, address) + if address == "93.184.216.34:18550" { + return nil, errors.New("first address unavailable") + } + clientConn, serverConn := net.Pipe() + go func() { + defer serverConn.Close() + request, err := http.ReadRequest(bufio.NewReader(serverConn)) + if err == nil { + request.Body.Close() + _, _ = serverConn.Write([]byte("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) + } + }() + return clientConn, nil + }) + request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()} + + require.NoError(t, client.SubmitBuilderPreferences(t.Context(), "http://builder.example:18550", common.Bytes48{}, request)) + require.Equal(t, 1, lookups) + require.Equal(t, []string{"93.184.216.34:18550", "93.184.216.35:18550"}, dialed) +} + +func TestDynamicBuilderDialAllFailAndCancellation(t *testing.T) { + addresses := []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("93.184.216.35")}} + request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()} + + t.Run("all fail", func(t *testing.T) { + var dialed []string + client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{}) + client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { return addresses, nil } + client.transport = newPinnedBuilderTransport(func(_ context.Context, _, address string) (net.Conn, error) { + dialed = append(dialed, address) + return nil, errors.New("unavailable") + }) + + require.Error(t, client.SubmitBuilderPreferences(t.Context(), "http://builder.example:18550", common.Bytes48{}, request)) + require.Equal(t, []string{"93.184.216.34:18550", "93.184.216.35:18550"}, dialed) + }) + + t.Run("canceled dial stops fallback", func(t *testing.T) { + var dialed []string + client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{}) + client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { return addresses, nil } + client.transport = newPinnedBuilderTransport(func(ctx context.Context, _, address string) (net.Conn, error) { + dialed = append(dialed, address) + <-ctx.Done() + return nil, ctx.Err() + }) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond) + defer cancel() + + require.ErrorIs(t, client.SubmitBuilderPreferences(ctx, "http://builder.example:18550", common.Bytes48{}, request), context.DeadlineExceeded) + require.Equal(t, []string{"93.184.216.34:18550"}, dialed) + }) +} + func TestPrivateBuilderTargetsRequireExplicitPolicy(t *testing.T) { request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()} rejected := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{}) diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 360997f373b..7c00bc7b3c1 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -2242,14 +2242,14 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac lenBlobs, ) // Broadcast the block and its blobs - if err := a.gossipManager.Publish(ctx, gossip.TopicNameBeaconBlock, blkSSZ); err != nil { - a.logger.Error("Failed to publish block", "err", err) + if err := a.publishGossip(ctx, gossip.TopicNameBeaconBlock, blkSSZ); err != nil { + return err } if blk.Version() < clparams.FuluVersion { for idx, blob := range blobsSidecarsBytes { - if err := a.gossipManager.Publish(ctx, gossip.TopicNameBlobSidecar(uint64(idx)), blob); err != nil { - a.logger.Error("Failed to publish blob sidecar", "err", err) + if err := a.publishGossip(ctx, gossip.TopicNameBlobSidecar(uint64(idx)), blob); err != nil { + return err } } } @@ -2262,8 +2262,8 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac continue } subnet := das.ComputeSubnetForDataColumnSidecar(column.Index) - if err := a.gossipManager.Publish(ctx, gossip.TopicNameDataColumnSidecar(subnet), columnSSZ); err != nil { - a.logger.Error("Failed to publish data column sidecar", "err", err) + if err := a.publishGossip(ctx, gossip.TopicNameDataColumnSidecar(subnet), columnSSZ); err != nil { + return err } } } @@ -2281,6 +2281,16 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac return nil } +func (a *ApiHandler) publishGossip(ctx context.Context, topic string, data []byte) error { + if a.gossipManager == nil { + return errors.New("gossip publisher unavailable") + } + if err := a.gossipManager.Publish(ctx, topic, data); err != nil { + return fmt.Errorf("publish %s: %w", topic, err) + } + return nil +} + func retryPublishedBlockStore(ctx context.Context, attempts int, delay time.Duration, store func(context.Context) error) error { var err error for attempt := range attempts { diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index f42e97da1ed..a367e55405e 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -40,11 +40,13 @@ import ( "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" + "github.com/erigontech/erigon/cl/gossip" blob_storage_mock "github.com/erigontech/erigon/cl/persistence/blob_storage/mock_services" "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/phase1/core/state/lru" "github.com/erigontech/erigon/cl/phase1/execution_client" "github.com/erigontech/erigon/cl/phase1/forkchoice" + gossip_mock "github.com/erigontech/erigon/cl/phase1/network/gossip/mock_services" network_services_mock "github.com/erigontech/erigon/cl/phase1/network/services/mock_services" sync_pool_mock "github.com/erigontech/erigon/cl/validator/sync_contribution_pool/mock_services" "github.com/erigontech/erigon/cl/validator/validator_params" @@ -400,6 +402,17 @@ func TestBroadcastBlockRunsGossipValidationBeforePublishing(t *testing.T) { require.ErrorContains(t, err, validationErr.Error()) } +func TestPublishGossipReturnsPublishFailure(t *testing.T) { + ctrl := gomock.NewController(t) + gossipManager := gossip_mock.NewMockGossip(ctrl) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, []byte{1}).Return(errors.New("gossip unavailable")) + handler := &ApiHandler{gossipManager: gossipManager} + + err := handler.publishGossip(t.Context(), gossip.TopicNameBeaconBlock, []byte{1}) + + require.ErrorContains(t, err, "gossip unavailable") +} + func TestPublishBlindedBlocksRejectsPreBellatrix(t *testing.T) { for _, version := range []clparams.StateVersion{clparams.Phase0Version, clparams.AltairVersion} { t.Run(version.String(), func(t *testing.T) { diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index 14c742e5b10..8f9feb5b0c4 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -788,6 +788,7 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit } canonical := strings.Contains(r.URL.Path, "/execution_payload_envelopes") blobDataIncluded := false + validation := BlockPublishingValidationGossip if canonical { if r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() { beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")).WriteTo(w) @@ -803,6 +804,11 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid Eth-Blob-Data-Included: %w", err)).WriteTo(w) return } + validation, err = a.parseBlockPublishingValidation(r, 2) + if err != nil { + beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + return + } } signedEnvelope, contents, err := a.decodeExecutionPayloadEnvelopeRequest(w, r, contentType, blobDataIncluded) if err != nil { @@ -814,6 +820,23 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("missing message in signed envelope")).WriteTo(w) return } + if validation == BlockPublishingValidationConsensusAndEquivocation { + block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot) + if !ok || block == nil || block.Block == nil { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("execution payload envelope block is unavailable")).WriteTo(w) + return + } + if a.forkchoiceStore.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, signedEnvelope.Message.BeaconBlockRoot) { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("execution payload envelope block has an equivocation")).WriteTo(w) + return + } + } + if validation == BlockPublishingValidationGossip { + if err := a.forkchoiceStore.ValidateExecutionPayloadEnvelope(signedEnvelope); err != nil { + beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + return + } + } contentsIntegrationFailed := false if contents != nil { if err := a.validateAndStoreExecutionPayloadEnvelopeContents(r.Context(), contents); err != nil { @@ -831,10 +854,20 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit } gossipValidated := false if err := a.forkchoiceStore.OnExecutionPayload(r.Context(), signedEnvelope, canonical, true); err != nil { + if canonical && !blobDataIncluded && errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) { + beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + return + } if errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) { a.logger.Debug("[Beacon REST] OnExecutionPayload queued or ignored", "err", err) status = http.StatusAccepted gossipValidated = errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) + } else if canonical && validation == BlockPublishingValidationGossip { + status = http.StatusAccepted + gossipValidated = true + } else if canonical { + beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + return } else { beaconhttp.WrapEndpointError(err).WriteTo(w) return @@ -866,15 +899,15 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit } } - // Broadcast the envelope on the execution_payload gossip topic - if a.sentinel != nil { + if canonical || a.sentinel != nil { encodedSSZ, err := signedEnvelope.EncodeSSZ(nil) if err != nil { beaconhttp.NewEndpointError(http.StatusInternalServerError, err).WriteTo(w) return } - if err := a.gossipManager.Publish(r.Context(), gossip.TopicNameExecutionPayload, encodedSSZ); err != nil { - a.logger.Debug("[Beacon REST] failed to publish execution payload envelope to gossip", "err", err) + if err := a.publishGossip(r.Context(), gossip.TopicNameExecutionPayload, encodedSSZ); err != nil { + beaconhttp.NewEndpointError(http.StatusInternalServerError, err).WriteTo(w) + return } } diff --git a/cl/beacon/handler/epbs_test.go b/cl/beacon/handler/epbs_test.go index ceb4f496b3f..f405747b13b 100644 --- a/cl/beacon/handler/epbs_test.go +++ b/cl/beacon/handler/epbs_test.go @@ -35,7 +35,9 @@ import ( "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" + "github.com/erigontech/erigon/cl/gossip" "github.com/erigontech/erigon/cl/phase1/forkchoice" + gossip_mock "github.com/erigontech/erigon/cl/phase1/network/gossip/mock_services" "github.com/erigontech/erigon/cl/phase1/network/services" mock_services "github.com/erigontech/erigon/cl/phase1/network/services/mock_services" "github.com/erigontech/erigon/cl/pool" @@ -246,7 +248,7 @@ func TestPostExecutionPayloadEnvelopesEmitsImportedAndAvailableEvents(t *testing } } -func TestPostExecutionPayloadEnvelopesEmitsGossipWhenWaitingForColumns(t *testing.T) { +func TestPostExecutionPayloadEnvelopesRejectsStatefulEnvelopeWhenColumnsAreMissing(t *testing.T) { _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) handler.emitters = beaconevents.NewEventEmitter() events := make(chan *beaconevents.EventStream, 2) @@ -271,8 +273,7 @@ func TestPostExecutionPayloadEnvelopesEmitsGossipWhenWaitingForColumns(t *testin handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) - require.Equal(t, http.StatusAccepted, recorder.Code, recorder.Body.String()) - require.Equal(t, beaconevents.OpExecutionPayloadGossip, (<-events).Event) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) select { case event := <-events: t.Fatalf("unexpected event %s", event.Event) @@ -280,6 +281,104 @@ func TestPostExecutionPayloadEnvelopesEmitsGossipWhenWaitingForColumns(t *testin } } +func TestPostExecutionPayloadEnvelopesRejectsInvalidBroadcastValidation(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + body, err := json.Marshal(envelope) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes?broadcast_validation=fast", strings.NewReader(string(body))) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Blob-Data-Included", "false") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) +} + +func TestPostExecutionPayloadEnvelopesRejectsEquivocatingBlockBeforeGossip(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + ctrl := gomock.NewController(t) + handler.gossipManager = gossip_mock.NewMockGossip(ctrl) + root := common.Hash{1} + fcu.Blocks = map[common.Hash]*cltypes.SignedBeaconBlock{ + root: {Block: &cltypes.BeaconBlock{Slot: 12, ProposerIndex: 3, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}, + {2}: {Block: &cltypes.BeaconBlock{Slot: 12, ProposerIndex: 3, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}, + } + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + envelope.Message.BeaconBlockRoot = root + body, err := json.Marshal(envelope) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes?broadcast_validation=consensus_and_equivocation", strings.NewReader(string(body))) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Blob-Data-Included", "false") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), "equivocation") +} + +func TestPostExecutionPayloadEnvelopesReturnsErrorWhenGossipPublishFails(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + ctrl := gomock.NewController(t) + gossipManager := gossip_mock.NewMockGossip(ctrl) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameExecutionPayload, gomock.Any()).Return(errors.New("gossip unavailable")) + handler.gossipManager = gossipManager + root := common.Hash{1} + fcu.Blocks = map[common.Hash]*cltypes.SignedBeaconBlock{ + root: {Block: &cltypes.BeaconBlock{Slot: 12, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}, + } + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + envelope.Message.BeaconBlockRoot = root + body, err := json.Marshal(envelope) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", strings.NewReader(string(body))) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Blob-Data-Included", "false") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + + require.Equal(t, http.StatusInternalServerError, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), "gossip unavailable") +} + +func TestPostExecutionPayloadEnvelopesClassifiesIntegrationFailureByValidationMode(t *testing.T) { + for _, test := range []struct { + validation string + status int + }{ + {validation: "gossip", status: http.StatusAccepted}, + {validation: "consensus", status: http.StatusBadRequest}, + } { + t.Run(test.validation, func(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + root := common.Hash{1} + fcu.Blocks = map[common.Hash]*cltypes.SignedBeaconBlock{ + root: {Block: &cltypes.BeaconBlock{Slot: 12, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}, + } + fcu.OnExecutionPayloadErr = errors.New("integration failed") + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + envelope.Message.BeaconBlockRoot = root + body, err := json.Marshal(envelope) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes?broadcast_validation="+test.validation, strings.NewReader(string(body))) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Blob-Data-Included", "false") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + require.Equal(t, test.status, recorder.Code, recorder.Body.String()) + }) + } +} + func TestPostPtcDutiesDoesNotCapValidatorCount(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) handler.beaconChainCfg.GloasForkEpoch = 0 diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index 85c5209ccc6..a18f8065726 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -36,6 +36,7 @@ import ( "github.com/erigontech/erigon/cl/phase1/core/state/lru" "github.com/erigontech/erigon/cl/phase1/execution_client" "github.com/erigontech/erigon/cl/phase1/forkchoice" + "github.com/erigontech/erigon/cl/transition" "github.com/erigontech/erigon/cl/transition/impl/eth2" "github.com/erigontech/erigon/cl/utils/eth_clock" "github.com/erigontech/erigon/common" @@ -160,6 +161,7 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac return fmt.Errorf("%w: block is not from a future slot: %d > %d", ErrIgnore, currentSlot, msg.Block.Slot) } blockEpoch := msg.Block.Slot / b.beaconCfg.SlotsPerEpoch + var finalizedCheckpoint solid.Checkpoint if err := b.syncedData.ViewHeadState(func(headState *state.CachingBeaconState) error { // [IGNORE] The block is from a slot greater than the latest finalized slot -- i.e. validate that signed_beacon_block.message.slot > compute_start_slot_at_epoch(store.finalized_checkpoint.epoch) @@ -167,6 +169,7 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac if blockEpoch <= headState.FinalizedCheckpoint().Epoch { return fmt.Errorf("%w: block is not from a slot greater than the latest finalized slot: %d > %d", ErrIgnore, blockEpoch, headState.FinalizedCheckpoint().Epoch) } + finalizedCheckpoint = headState.FinalizedCheckpoint() if ok, err := eth2.VerifyBlockSignature(headState, msg); err != nil { return err @@ -192,6 +195,30 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac if parentHeader.Slot >= msg.Block.Slot { return ErrBlockYoungerThanParent } + finalizedSlot, ok := safeMultiplyUint64(finalizedCheckpoint.Epoch, b.beaconCfg.SlotsPerEpoch) + if !ok { + return errors.New("finalized checkpoint slot is not representable") + } + if b.forkchoiceStore.Ancestor(msg.Block.ParentRoot, finalizedSlot).Root != finalizedCheckpoint.Root { + return errors.New("finalized checkpoint is not an ancestor of block") + } + parentState, err := b.forkchoiceStore.GetStateAtBlockRoot(msg.Block.ParentRoot, true) + if err != nil { + return fmt.Errorf("get parent block state: %w", err) + } + if parentState == nil { + return errors.New("parent block state not found") + } + if err := transition.DefaultMachine.ProcessSlots(parentState, msg.Block.Slot); err != nil { + return fmt.Errorf("process parent state to block slot: %w", err) + } + expectedProposer, err := parentState.GetBeaconProposerIndexForSlot(msg.Block.Slot) + if err != nil { + return fmt.Errorf("get expected proposer: %w", err) + } + if msg.Block.ProposerIndex != expectedProposer { + return fmt.Errorf("block proposer index %d does not match expected proposer %d", msg.Block.ProposerIndex, expectedProposer) + } epoch := msg.Block.Slot / b.beaconCfg.SlotsPerEpoch blockVersion := b.beaconCfg.GetCurrentStateVersion(epoch) @@ -221,20 +248,25 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac return errors.New("bid.parent_block_root does not match block.parent_root") } - // [IGNORE] The block's parent execution payload (defined by bid.parent_block_hash) has been seen - // (via gossip or non-gossip sources). A client MAY queue blocks for processing once the parent payload is retrieved. - // If execution_payload verification of block's execution payload parent by an execution node is complete: - // [REJECT] The block's execution payload parent (defined by bid.parent_block_hash) passes all validation. - parentBlockHash := bid.Message.ParentBlockHash - status, seen := b.forkchoiceStore.GetRecentExecutionPayloadStatus(parentBlockHash) - if !seen { - if schedule != nil { - schedule() - } - return fmt.Errorf("%w: parent execution payload not seen: %v", ErrIgnore, parentBlockHash) + parentBlock, ok := b.forkchoiceStore.GetBlock(msg.Block.ParentRoot) + if !ok || parentBlock == nil || parentBlock.Block == nil || parentBlock.Block.Body == nil { + return errors.New("parent block not found") } - if status == execution_client.PayloadStatusInvalidated { - return errors.New("parent execution payload is invalid") + parentBid := parentBlock.Block.Body.GetSignedExecutionPayloadBid() + parentIsFull := parentBid != nil && parentBid.Message != nil && bid.Message.ParentBlockHash == parentBid.Message.BlockHash + if parentIsFull { + status, seen := b.forkchoiceStore.GetRecentExecutionPayloadStatusByRoot(msg.Block.ParentRoot) + if status == execution_client.PayloadStatusInvalidated { + return errors.New("parent execution payload is invalid") + } + if !seen || status != execution_client.PayloadStatusValidated { + if schedule != nil { + schedule() + } + return fmt.Errorf("%w: parent payload is not verified", ErrIgnore) + } + } else if bid.Message.ParentBlockHash != parentState.GetLatestBlockHash() { + return errors.New("bid does not build on the parent's execution head") } } else if msg.Block.Body.BlobKzgCommitments != nil && msg.Block.Body.BlobKzgCommitments.Len() > int(maxBlobsPerBlock) { // Pre-GLOAS: [REJECT] The length of KZG commitments is less than or equal to the limitation defined in Consensus Layer diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index 27e61ededdf..458b5c4bcdf 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -30,9 +30,15 @@ import ( "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" + "github.com/erigontech/erigon/cl/fork" + "github.com/erigontech/erigon/cl/phase1/core/state" + "github.com/erigontech/erigon/cl/phase1/execution_client" "github.com/erigontech/erigon/cl/phase1/forkchoice" "github.com/erigontech/erigon/cl/phase1/forkchoice/mock_services" + "github.com/erigontech/erigon/cl/transition" + "github.com/erigontech/erigon/cl/utils/bls" "github.com/erigontech/erigon/cl/utils/eth_clock" + "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv/dbcfg" "github.com/erigontech/erigon/db/kv/mdbx/mdbxtest" @@ -153,7 +159,10 @@ func TestBlockServiceSuccess(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - blocks, _, post := tests.GetBellatrixRandom() + blocks, pre, post := tests.GetBellatrixRandom() + parentState, err := pre.Copy() + require.NoError(t, err) + require.NoError(t, transition.TransitionState(parentState, blocks[0], nil, false)) blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl) require.NoError(t, syncedData.OnHeadState(post)) @@ -161,11 +170,172 @@ func TestBlockServiceSuccess(t *testing.T) { ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes() fcu.FinalizedCheckpointVal = post.FinalizedCheckpoint() fcu.Headers[blocks[1].Block.ParentRoot] = blocks[0].SignedBeaconBlockHeader().Header.Copy() + fcu.StateAtBlockRootVal[blocks[1].Block.ParentRoot] = parentState + finalizedSlot := post.FinalizedCheckpoint().Epoch * post.BeaconConfig().SlotsPerEpoch + fcu.Ancestors[finalizedSlot] = forkchoice.ForkChoiceNode{Root: post.FinalizedCheckpoint().Root} blocks[1].Block.Body.BlobKzgCommitments = solid.NewStaticListSSZ[*cltypes.KZGCommitment](100, 48) require.NoError(t, blockService.ProcessMessage(context.Background(), nil, blocks[1])) } +func TestBlockServiceGossipRejectsBlockOutsideFinalizedChain(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + blocks, _, post := tests.GetBellatrixRandom() + blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl) + syncedData.OnHeadState(post) + ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes() + ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes() + fcu.FinalizedCheckpointVal = post.FinalizedCheckpoint() + fcu.Headers[blocks[1].Block.ParentRoot] = blocks[0].SignedBeaconBlockHeader().Header.Copy() + finalizedSlot := post.FinalizedCheckpoint().Epoch * post.BeaconConfig().SlotsPerEpoch + fcu.Ancestors[finalizedSlot] = forkchoice.ForkChoiceNode{Root: common.Hash{0xff}} + + err := blockService.ValidateGossip(t.Context(), blocks[1]) + require.ErrorContains(t, err, "finalized checkpoint is not an ancestor") +} + +func TestBlockServiceGossipRejectsUnexpectedProposer(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + blocks, pre, post := tests.GetBellatrixRandom() + parentState, err := pre.Copy() + require.NoError(t, err) + require.NoError(t, transition.TransitionState(parentState, blocks[0], nil, false)) + targetEpoch := blocks[1].Block.Slot / parentState.BeaconConfig().SlotsPerEpoch + mixPosition := (targetEpoch + parentState.BeaconConfig().EpochsPerHistoricalVector - parentState.BeaconConfig().MinSeedLookahead - 1) % parentState.BeaconConfig().EpochsPerHistoricalVector + foundUnexpectedProposer := false + for nonce := 1; nonce <= 255; nonce++ { + parentState.SetRandaoMixAt(int(mixPosition), common.Hash{byte(nonce)}) + expected, proposerErr := parentState.GetBeaconProposerIndexForSlot(blocks[1].Block.Slot) + require.NoError(t, proposerErr) + if expected != blocks[1].Block.ProposerIndex { + foundUnexpectedProposer = true + break + } + } + require.True(t, foundUnexpectedProposer) + + blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl) + syncedData.OnHeadState(post) + ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes() + ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes() + fcu.FinalizedCheckpointVal = post.FinalizedCheckpoint() + fcu.Headers[blocks[1].Block.ParentRoot] = blocks[0].SignedBeaconBlockHeader().Header.Copy() + fcu.Blocks[blocks[1].Block.ParentRoot] = blocks[0] + fcu.StateAtBlockRootVal[blocks[1].Block.ParentRoot] = parentState + finalizedSlot := post.FinalizedCheckpoint().Epoch * post.BeaconConfig().SlotsPerEpoch + fcu.Ancestors[finalizedSlot] = forkchoice.ForkChoiceNode{Root: post.FinalizedCheckpoint().Root} + + err = blockService.ValidateGossip(t.Context(), blocks[1]) + require.ErrorContains(t, err, "does not match expected proposer") +} + +func TestBlockServiceGossipWaitsForFullParentPayloadVerification(t *testing.T) { + service, child, fcu, parentRoot, parentBlockHash := newGloasGossipValidationFixture(t, nil) + fcu.ExecutionPayloadStatusMap[parentBlockHash] = execution_client.PayloadStatusValidated + + err := service.ValidateGossip(t.Context(), child) + require.ErrorContains(t, err, "parent payload is not verified") + require.NotContains(t, fcu.PayloadStatusByRootMap, parentRoot) +} + +func TestBlockServiceGossipAcceptsVerifiedFullParentPayload(t *testing.T) { + service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated + require.NoError(t, service.ValidateGossip(t.Context(), child)) +} + +func TestBlockServiceGossipRejectsWrongEmptyParentExecutionHead(t *testing.T) { + service, child, _, _, _ := newGloasGossipValidationFixture(t, func(common.Hash, common.Hash) common.Hash { + return common.Hash{0x99} + }) + require.ErrorContains(t, service.ValidateGossip(t.Context(), child), "does not build on the parent's execution head") +} + +func TestBlockServiceGossipAcceptsEmptyParentExecutionHead(t *testing.T) { + service, child, _, _, _ := newGloasGossipValidationFixture(t, func(parentExecutionHead, _ common.Hash) common.Hash { + return parentExecutionHead + }) + require.NoError(t, service.ValidateGossip(t.Context(), child)) +} + +func newGloasGossipValidationFixture(t *testing.T, childParentHash func(parentExecutionHead, parentBlockHash common.Hash) common.Hash) (BlockService, *cltypes.SignedBeaconBlock, *mock_services.ForkChoiceStorageMock, common.Hash, common.Hash) { + t.Helper() + ctrl := gomock.NewController(t) + cfg := clparams.MainnetBeaconConfig + cfg.AltairForkEpoch = 0 + cfg.BellatrixForkEpoch = 0 + cfg.CapellaForkEpoch = 0 + cfg.DenebForkEpoch = 0 + cfg.ElectraForkEpoch = 0 + cfg.FuluForkEpoch = 0 + cfg.GloasForkEpoch = 0 + parentSlot := cfg.SlotsPerEpoch + childSlot := parentSlot + 1 + + privateKey, err := bls.GenerateKey() + require.NoError(t, err) + validator := solid.NewValidator() + var pubkey [48]byte + copy(pubkey[:], bls.CompressPublicKey(privateKey.PublicKey())) + validator.SetPublicKey(pubkey) + validator.SetActivationEpoch(0) + validator.SetExitEpoch(cfg.FarFutureEpoch) + validator.SetEffectiveBalance(cfg.MaxEffectiveBalance) + parentState := state.New(&cfg) + parentState.SetVersion(clparams.GloasVersion) + parentState.SetSlot(parentSlot) + parentState.AddValidator(validator, cfg.MaxEffectiveBalance) + parentState.SetProposerLookahead(solid.NewUint64VectorSSZ(int((cfg.MinSeedLookahead + 1) * cfg.SlotsPerEpoch))) + parentExecutionHead := common.Hash{0x11} + parentState.SetLatestBlockHash(parentExecutionHead) + + parentBlockHash := common.Hash{0x22} + parent := cltypes.NewSignedBeaconBlock(&cfg, clparams.GloasVersion) + parent.Block.Slot = parentSlot + parent.Block.ProposerIndex = 0 + parent.Block.Body.SignedExecutionPayloadBid = &cltypes.SignedExecutionPayloadBid{Message: &cltypes.ExecutionPayloadBid{ + ParentBlockHash: parentExecutionHead, + BlockHash: parentBlockHash, + }} + parentRoot, err := parent.Block.HashSSZ() + require.NoError(t, err) + + child := cltypes.NewSignedBeaconBlock(&cfg, clparams.GloasVersion) + child.Block.Slot = childSlot + child.Block.ProposerIndex = 0 + child.Block.ParentRoot = parentRoot + selectedParentHash := parentBlockHash + if childParentHash != nil { + selectedParentHash = childParentHash(parentExecutionHead, parentBlockHash) + } + child.Block.Body.SignedExecutionPayloadBid = &cltypes.SignedExecutionPayloadBid{Message: &cltypes.ExecutionPayloadBid{ + ParentBlockHash: selectedParentHash, + ParentBlockRoot: parentRoot, + }} + domain, err := parentState.GetDomain(cfg.DomainBeaconProposer, childSlot/cfg.SlotsPerEpoch) + require.NoError(t, err) + signingRoot, err := fork.ComputeSigningRoot(child.Block, domain) + require.NoError(t, err) + copy(child.Signature[:], privateKey.Sign(signingRoot[:]).Bytes()) + + db := mdbxtest.NewTestDB(t, dbcfg.ChainDB) + syncedDataManager := synced_data.NewSyncedDataManager(&cfg, true) + require.NoError(t, syncedDataManager.OnHeadState(parentState)) + ethClock := eth_clock.NewMockEthereumClock(ctrl) + ethClock.EXPECT().GetCurrentSlot().Return(childSlot).AnyTimes() + ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes() + fcu := mock_services.NewForkChoiceStorageMock(t) + fcu.Headers[parentRoot] = parent.SignedBeaconBlockHeader().Header.Copy() + fcu.Blocks[parentRoot] = parent + fcu.StateAtBlockRootVal[parentRoot] = parentState + service := NewBlockService(t.Context(), db, fcu, syncedDataManager, ethClock, &cfg, nil) + return service, child, fcu, parentRoot, parentBlockHash +} + func TestImportBlockOperationsAttesterSlashingLogging(t *testing.T) { tests := []struct { name string diff --git a/cl/phase1/stages/forkchoice.go b/cl/phase1/stages/forkchoice.go index 7f481a09389..f053583eada 100644 --- a/cl/phase1/stages/forkchoice.go +++ b/cl/phase1/stages/forkchoice.go @@ -239,8 +239,20 @@ func emitHeadEvent(cfg *Cfg, headSlot uint64, headRoot common.Hash, headState *s if err != nil { return err } - // emit the head event - cfg.emitter.State().SendHead(&beaconevents.HeadData{ + return emitHeadEventsIfCurrent(cfg.emitter, headEvent, headSlot, headRoot, stateRoot, func() (common.Hash, uint64, error) { + return cfg.forkChoice.GetHead(nil) + }) +} + +func emitHeadEventsIfCurrent(emitter *beaconevents.EventEmitter, headEvent *beaconevents.HeadV2Data, headSlot uint64, headRoot, stateRoot common.Hash, getHead func() (common.Hash, uint64, error)) error { + currentRoot, currentSlot, err := getHead() + if err != nil { + return fmt.Errorf("failed to revalidate head event: %w", err) + } + if currentRoot != headRoot || currentSlot != headSlot { + return nil + } + emitter.State().SendHead(&beaconevents.HeadData{ Slot: headSlot, Block: headRoot, State: stateRoot, @@ -249,7 +261,7 @@ func emitHeadEvent(cfg *Cfg, headSlot uint64, headRoot common.Hash, headState *s CurrentDutyDependentRoot: headEvent.Data.NextEpochDependentRoot, ExecutionOptimistic: false, }) - cfg.emitter.State().SendHeadV2(headEvent) + emitter.State().SendHeadV2(headEvent) return nil } diff --git a/cl/phase1/stages/forkchoice_test.go b/cl/phase1/stages/forkchoice_test.go index 960fe6004eb..f7643a869cb 100644 --- a/cl/phase1/stages/forkchoice_test.go +++ b/cl/phase1/stages/forkchoice_test.go @@ -256,3 +256,17 @@ func drainReorgEvent(t *testing.T, ctx context.Context, tx kv.RwTx, headSlot uin } } } + +func TestEmitHeadEventsDropsStaleSnapshot(t *testing.T) { + emitter := beaconevents.NewEventEmitter() + ch := make(chan *beaconevents.EventStream, 2) + sub := emitter.State().Subscribe(ch) + defer sub.Unsubscribe() + headRoot := common.Hash{1} + + err := emitHeadEventsIfCurrent(emitter, &beaconevents.HeadV2Data{}, 10, headRoot, common.Hash{2}, func() (common.Hash, uint64, error) { + return common.Hash{3}, 11, nil + }) + require.NoError(t, err) + require.Empty(t, ch) +} From ab2060e0979c86faafeeb191a6525b110126a773 Mon Sep 17 00:00:00 2001 From: kewei Date: Tue, 25 Aug 2026 21:07:30 +0800 Subject: [PATCH 06/43] cl: close Gloas API review gaps --- cl/beacon/builder/client.go | 21 ++++-- cl/beacon/builder/client_test.go | 40 +++++++++-- cl/beacon/handler/epbs.go | 11 +++ cl/phase1/network/services/block_service.go | 69 +++++++++++-------- .../network/services/block_service_test.go | 29 +++++++- cl/phase1/stages/forkchoice.go | 17 +++-- cl/phase1/stages/forkchoice_test.go | 19 ++++- 7 files changed, 154 insertions(+), 52 deletions(-) diff --git a/cl/beacon/builder/client.go b/cl/beacon/builder/client.go index 069a7fc0247..c5716db27dd 100644 --- a/cl/beacon/builder/client.go +++ b/cl/beacon/builder/client.go @@ -415,7 +415,11 @@ type builderHTTPResponse struct { } func (b *builderClient) builderCall(ctx context.Context, method string, target builderTarget, headers map[string]string, body io.Reader) (*builderHTTPResponse, error) { - requestContext := context.WithValue(ctx, pinnedBuilderTargetKey{}, pinnedBuilderTarget{hostname: target.hostname, ips: target.ips}) + var attemptTimeout time.Duration + if deadline, ok := ctx.Deadline(); ok && len(target.ips) > 1 { + attemptTimeout = time.Until(deadline) / time.Duration(len(target.ips)) + } + requestContext := context.WithValue(ctx, pinnedBuilderTargetKey{}, pinnedBuilderTarget{hostname: target.hostname, ips: target.ips, attemptTimeout: attemptTimeout}) request, err := http.NewRequestWithContext(requestContext, method, target.url, body) if err != nil { return nil, err @@ -469,8 +473,9 @@ func (b *builderClient) builderAdmission() *semaphore.Weighted { type pinnedBuilderTargetKey struct{} type pinnedBuilderTarget struct { - hostname string - ips []net.IP + hostname string + ips []net.IP + attemptTimeout time.Duration } func newPinnedBuilderTransport(dialContext func(context.Context, string, string) (net.Conn, error)) http.RoundTripper { @@ -492,12 +497,18 @@ func newPinnedBuilderTransport(dialContext func(context.Context, string, string) return nil, errors.New("builder dial target does not match resolved host") } var dialErrors []error - for _, ip := range pinned.ips { + for i, ip := range pinned.ips { if err := ctx.Err(); err != nil { return nil, err } pinnedAddress := net.JoinHostPort(ip.String(), port) - conn, err := dialContext(ctx, network, pinnedAddress) + attemptCtx := ctx + cancel := func() {} + if i+1 < len(pinned.ips) && pinned.attemptTimeout > 0 { + attemptCtx, cancel = context.WithTimeout(ctx, pinned.attemptTimeout) + } + conn, err := dialContext(attemptCtx, network, pinnedAddress) + cancel() if err == nil { return conn, nil } diff --git a/cl/beacon/builder/client_test.go b/cl/beacon/builder/client_test.go index b8164c79bf9..5e08034af61 100644 --- a/cl/beacon/builder/client_test.go +++ b/cl/beacon/builder/client_test.go @@ -618,21 +618,49 @@ func TestDynamicBuilderDialAllFailAndCancellation(t *testing.T) { t.Run("canceled dial stops fallback", func(t *testing.T) { var dialed []string + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{}) client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { return addresses, nil } - client.transport = newPinnedBuilderTransport(func(ctx context.Context, _, address string) (net.Conn, error) { + client.transport = newPinnedBuilderTransport(func(dialCtx context.Context, _, address string) (net.Conn, error) { dialed = append(dialed, address) - <-ctx.Done() - return nil, ctx.Err() + cancel() + <-dialCtx.Done() + return nil, dialCtx.Err() }) - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond) - defer cancel() - require.ErrorIs(t, client.SubmitBuilderPreferences(ctx, "http://builder.example:18550", common.Bytes48{}, request), context.DeadlineExceeded) + require.ErrorIs(t, client.SubmitBuilderPreferences(ctx, "http://builder.example:18550", common.Bytes48{}, request), context.Canceled) require.Equal(t, []string{"93.184.216.34:18550"}, dialed) }) } +func TestDynamicBuilderDialBlackholeDoesNotConsumeFallbackDeadline(t *testing.T) { + addresses := []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("93.184.216.35")}} + client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{}) + client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { return addresses, nil } + client.transport = newPinnedBuilderTransport(func(ctx context.Context, _, address string) (net.Conn, error) { + if address == "93.184.216.34:18550" { + <-ctx.Done() + return nil, ctx.Err() + } + clientConn, serverConn := net.Pipe() + go func() { + defer serverConn.Close() + request, err := http.ReadRequest(bufio.NewReader(serverConn)) + if err == nil { + request.Body.Close() + _, _ = serverConn.Write([]byte("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) + } + }() + return clientConn, nil + }) + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()} + + require.NoError(t, client.SubmitBuilderPreferences(ctx, "http://builder.example:18550", common.Bytes48{}, request)) +} + func TestPrivateBuilderTargetsRequireExplicitPolicy(t *testing.T) { request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()} rejected := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{}) diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index 8f9feb5b0c4..fd659c238c5 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -858,6 +858,10 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) return } + if canonical && validation != BlockPublishingValidationGossip { + beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + return + } if errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) { a.logger.Debug("[Beacon REST] OnExecutionPayload queued or ignored", "err", err) status = http.StatusAccepted @@ -900,6 +904,13 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit } if canonical || a.sentinel != nil { + if validation == BlockPublishingValidationConsensusAndEquivocation { + block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot) + if !ok || block == nil || block.Block == nil || a.forkchoiceStore.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, signedEnvelope.Message.BeaconBlockRoot) { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("execution payload envelope block has an equivocation")).WriteTo(w) + return + } + } encodedSSZ, err := signedEnvelope.EncodeSSZ(nil) if err != nil { beaconhttp.NewEndpointError(http.StatusInternalServerError, err).WriteTo(w) diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index a18f8065726..3b614dde783 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -133,6 +133,7 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp if err := b.validateGossip(ctx, msg, func() { b.scheduleBlockForLaterProcessing(msg) }); err != nil { return err } + b.seenBlocksCache.Add(seenCacheKey, struct{}{}) b.publishBlockGossipEvent(msg) // the rest of the validation is done in the forkchoice store if err := b.processAndStoreBlock(ctx, msg); err != nil { @@ -160,14 +161,17 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac if currentSlot < msg.Block.Slot && !b.ethClock.IsSlotCurrentSlotWithMaximumClockDisparity(msg.Block.Slot) { return fmt.Errorf("%w: block is not from a future slot: %d > %d", ErrIgnore, currentSlot, msg.Block.Slot) } - blockEpoch := msg.Block.Slot / b.beaconCfg.SlotsPerEpoch var finalizedCheckpoint solid.Checkpoint if err := b.syncedData.ViewHeadState(func(headState *state.CachingBeaconState) error { // [IGNORE] The block is from a slot greater than the latest finalized slot -- i.e. validate that signed_beacon_block.message.slot > compute_start_slot_at_epoch(store.finalized_checkpoint.epoch) // (a client MAY choose to validate and store such blocks for additional purposes -- e.g. slashing detection, archive nodes, etc). - if blockEpoch <= headState.FinalizedCheckpoint().Epoch { - return fmt.Errorf("%w: block is not from a slot greater than the latest finalized slot: %d > %d", ErrIgnore, blockEpoch, headState.FinalizedCheckpoint().Epoch) + finalizedStartSlot, ok := safeMultiplyUint64(headState.FinalizedCheckpoint().Epoch, b.beaconCfg.SlotsPerEpoch) + if !ok { + return errors.New("finalized checkpoint slot is not representable") + } + if msg.Block.Slot <= finalizedStartSlot { + return fmt.Errorf("%w: block slot %d is not after finalized slot %d", ErrIgnore, msg.Block.Slot, finalizedStartSlot) } finalizedCheckpoint = headState.FinalizedCheckpoint() @@ -195,10 +199,39 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac if parentHeader.Slot >= msg.Block.Slot { return ErrBlockYoungerThanParent } + epoch := msg.Block.Slot / b.beaconCfg.SlotsPerEpoch + blockVersion := b.beaconCfg.GetCurrentStateVersion(epoch) + var gloasBid *cltypes.ExecutionPayloadBid + parentIsFull := false + if blockVersion >= clparams.GloasVersion { + signedBid := msg.Block.Body.GetSignedExecutionPayloadBid() + if signedBid == nil || signedBid.Message == nil { + return errors.New("missing signed_execution_payload_bid in GLOAS block") + } + gloasBid = signedBid.Message + parentBlock, ok := b.forkchoiceStore.GetBlock(msg.Block.ParentRoot) + if !ok || parentBlock == nil || parentBlock.Block == nil || parentBlock.Block.Body == nil { + return errors.New("parent block not found") + } + parentBid := parentBlock.Block.Body.GetSignedExecutionPayloadBid() + parentIsFull = parentBid != nil && parentBid.Message != nil && gloasBid.ParentBlockHash == parentBid.Message.BlockHash + if parentIsFull { + status, seen := b.forkchoiceStore.GetRecentExecutionPayloadStatusByRoot(msg.Block.ParentRoot) + if !seen || status != execution_client.PayloadStatusValidated { + if schedule != nil { + schedule() + } + return fmt.Errorf("%w: parent payload is not verified", ErrIgnore) + } + } + } finalizedSlot, ok := safeMultiplyUint64(finalizedCheckpoint.Epoch, b.beaconCfg.SlotsPerEpoch) if !ok { return errors.New("finalized checkpoint slot is not representable") } + if anchorSlot := b.forkchoiceStore.AnchorSlot(); finalizedSlot < anchorSlot { + finalizedSlot = anchorSlot + } if b.forkchoiceStore.Ancestor(msg.Block.ParentRoot, finalizedSlot).Root != finalizedCheckpoint.Root { return errors.New("finalized checkpoint is not an ancestor of block") } @@ -220,8 +253,6 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac return fmt.Errorf("block proposer index %d does not match expected proposer %d", msg.Block.ProposerIndex, expectedProposer) } - epoch := msg.Block.Slot / b.beaconCfg.SlotsPerEpoch - blockVersion := b.beaconCfg.GetCurrentStateVersion(epoch) var maxBlobsPerBlock uint64 if blockVersion >= clparams.FuluVersion { maxBlobsPerBlock = b.beaconCfg.GetBlobParameters(epoch).MaxBlobsPerBlock @@ -232,40 +263,18 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac // [Modified in Gloas:EIP7732] KZG commitments and execution payload validations moved from block.body to bid if blockVersion >= clparams.GloasVersion { // GLOAS: validate using bid = signed_execution_payload_bid.message - bid := msg.Block.Body.GetSignedExecutionPayloadBid() - if bid == nil || bid.Message == nil { - return errors.New("missing signed_execution_payload_bid in GLOAS block") - } - // [REJECT] The length of KZG commitments is less than or equal to the limitation defined in Consensus Layer // i.e. validate that len(bid.blob_kzg_commitments) <= get_blob_parameters(get_current_epoch(state)).max_blobs_per_block - if bid.Message.BlobKzgCommitments.Len() > int(maxBlobsPerBlock) { + if gloasBid.BlobKzgCommitments.Len() > int(maxBlobsPerBlock) { return ErrInvalidCommitmentsCount } // [REJECT] The bid's parent (defined by bid.parent_block_root) equals the block's parent (defined by block.parent_root) - if bid.Message.ParentBlockRoot != msg.Block.ParentRoot { + if gloasBid.ParentBlockRoot != msg.Block.ParentRoot { return errors.New("bid.parent_block_root does not match block.parent_root") } - parentBlock, ok := b.forkchoiceStore.GetBlock(msg.Block.ParentRoot) - if !ok || parentBlock == nil || parentBlock.Block == nil || parentBlock.Block.Body == nil { - return errors.New("parent block not found") - } - parentBid := parentBlock.Block.Body.GetSignedExecutionPayloadBid() - parentIsFull := parentBid != nil && parentBid.Message != nil && bid.Message.ParentBlockHash == parentBid.Message.BlockHash - if parentIsFull { - status, seen := b.forkchoiceStore.GetRecentExecutionPayloadStatusByRoot(msg.Block.ParentRoot) - if status == execution_client.PayloadStatusInvalidated { - return errors.New("parent execution payload is invalid") - } - if !seen || status != execution_client.PayloadStatusValidated { - if schedule != nil { - schedule() - } - return fmt.Errorf("%w: parent payload is not verified", ErrIgnore) - } - } else if bid.Message.ParentBlockHash != parentState.GetLatestBlockHash() { + if !parentIsFull && gloasBid.ParentBlockHash != parentState.GetLatestBlockHash() { return errors.New("bid does not build on the parent's execution head") } } else if msg.Block.Body.BlobKzgCommitments != nil && msg.Block.Body.BlobKzgCommitments.Len() > int(maxBlobsPerBlock) { diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index 458b5c4bcdf..31a4437b60e 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -164,7 +164,7 @@ func TestBlockServiceSuccess(t *testing.T) { require.NoError(t, err) require.NoError(t, transition.TransitionState(parentState, blocks[0], nil, false)) - blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl) + service, syncedData, ethClock, fcu := setupBlockService(t, ctrl) require.NoError(t, syncedData.OnHeadState(post)) ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes() ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes() @@ -175,7 +175,11 @@ func TestBlockServiceSuccess(t *testing.T) { fcu.Ancestors[finalizedSlot] = forkchoice.ForkChoiceNode{Root: post.FinalizedCheckpoint().Root} blocks[1].Block.Body.BlobKzgCommitments = solid.NewStaticListSSZ[*cltypes.KZGCommitment](100, 48) - require.NoError(t, blockService.ProcessMessage(context.Background(), nil, blocks[1])) + require.NoError(t, service.ProcessMessage(context.Background(), nil, blocks[1])) + require.True(t, service.(*blockService).seenBlocksCache.Contains(proposerIndexAndSlot{ + proposerIndex: blocks[1].Block.ProposerIndex, + slot: blocks[1].Block.Slot, + })) } func TestBlockServiceGossipRejectsBlockOutsideFinalizedChain(t *testing.T) { @@ -196,6 +200,27 @@ func TestBlockServiceGossipRejectsBlockOutsideFinalizedChain(t *testing.T) { require.ErrorContains(t, err, "finalized checkpoint is not an ancestor") } +func TestBlockServiceGossipUsesCheckpointSyncAnchorForFinalizedAncestor(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + blocks, pre, post := tests.GetBellatrixRandom() + parentState, err := pre.Copy() + require.NoError(t, err) + require.NoError(t, transition.TransitionState(parentState, blocks[0], nil, false)) + blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl) + syncedData.OnHeadState(post) + ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes() + ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes() + fcu.FinalizedCheckpointVal = post.FinalizedCheckpoint() + fcu.Headers[blocks[1].Block.ParentRoot] = blocks[0].SignedBeaconBlockHeader().Header.Copy() + fcu.StateAtBlockRootVal[blocks[1].Block.ParentRoot] = parentState + fcu.AnchorSlotVal = blocks[0].Block.Slot + fcu.Ancestors[fcu.AnchorSlotVal] = forkchoice.ForkChoiceNode{Root: post.FinalizedCheckpoint().Root} + + require.NoError(t, blockService.ValidateGossip(t.Context(), blocks[1])) +} + func TestBlockServiceGossipRejectsUnexpectedProposer(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/cl/phase1/stages/forkchoice.go b/cl/phase1/stages/forkchoice.go index f053583eada..f4bfcb05434 100644 --- a/cl/phase1/stages/forkchoice.go +++ b/cl/phase1/stages/forkchoice.go @@ -227,29 +227,32 @@ func emitHeadEvent(cfg *Cfg, headSlot uint64, headRoot common.Hash, headState *s if currentHeadRoot != headRoot || currentHeadSlot != headSlot { return nil } + payloadStatus := beaconevents.PayloadStatusName(cfg.forkChoice.GetHeadPayloadStatus()) + executionOptimistic := cfg.forkChoice.IsHeadOptimistic() headEvent, err := beaconevents.BuildHeadV2Data( cfg.beaconCfg, headState, headSlot, headRoot, stateRoot, - beaconevents.PayloadStatusName(cfg.forkChoice.GetHeadPayloadStatus()), - cfg.forkChoice.IsHeadOptimistic(), + payloadStatus, + executionOptimistic, ) if err != nil { return err } - return emitHeadEventsIfCurrent(cfg.emitter, headEvent, headSlot, headRoot, stateRoot, func() (common.Hash, uint64, error) { - return cfg.forkChoice.GetHead(nil) + return emitHeadEventsIfCurrent(cfg.emitter, headEvent, headSlot, headRoot, stateRoot, func() (common.Hash, uint64, string, bool, error) { + root, slot, err := cfg.forkChoice.GetHead(nil) + return root, slot, beaconevents.PayloadStatusName(cfg.forkChoice.GetHeadPayloadStatus()), cfg.forkChoice.IsHeadOptimistic(), err }) } -func emitHeadEventsIfCurrent(emitter *beaconevents.EventEmitter, headEvent *beaconevents.HeadV2Data, headSlot uint64, headRoot, stateRoot common.Hash, getHead func() (common.Hash, uint64, error)) error { - currentRoot, currentSlot, err := getHead() +func emitHeadEventsIfCurrent(emitter *beaconevents.EventEmitter, headEvent *beaconevents.HeadV2Data, headSlot uint64, headRoot, stateRoot common.Hash, getHead func() (common.Hash, uint64, string, bool, error)) error { + currentRoot, currentSlot, payloadStatus, executionOptimistic, err := getHead() if err != nil { return fmt.Errorf("failed to revalidate head event: %w", err) } - if currentRoot != headRoot || currentSlot != headSlot { + if currentRoot != headRoot || currentSlot != headSlot || payloadStatus != headEvent.Data.PayloadStatus || executionOptimistic != headEvent.Data.ExecutionOptimistic { return nil } emitter.State().SendHead(&beaconevents.HeadData{ diff --git a/cl/phase1/stages/forkchoice_test.go b/cl/phase1/stages/forkchoice_test.go index f7643a869cb..67da0346b44 100644 --- a/cl/phase1/stages/forkchoice_test.go +++ b/cl/phase1/stages/forkchoice_test.go @@ -264,8 +264,23 @@ func TestEmitHeadEventsDropsStaleSnapshot(t *testing.T) { defer sub.Unsubscribe() headRoot := common.Hash{1} - err := emitHeadEventsIfCurrent(emitter, &beaconevents.HeadV2Data{}, 10, headRoot, common.Hash{2}, func() (common.Hash, uint64, error) { - return common.Hash{3}, 11, nil + err := emitHeadEventsIfCurrent(emitter, &beaconevents.HeadV2Data{}, 10, headRoot, common.Hash{2}, func() (common.Hash, uint64, string, bool, error) { + return common.Hash{3}, 11, "pending", false, nil + }) + require.NoError(t, err) + require.Empty(t, ch) +} + +func TestEmitHeadEventsDropsStalePayloadStatus(t *testing.T) { + emitter := beaconevents.NewEventEmitter() + ch := make(chan *beaconevents.EventStream, 2) + sub := emitter.State().Subscribe(ch) + defer sub.Unsubscribe() + headRoot := common.Hash{1} + headEvent := &beaconevents.HeadV2Data{Data: beaconevents.HeadV2Content{PayloadStatus: "pending"}} + + err := emitHeadEventsIfCurrent(emitter, headEvent, 10, headRoot, common.Hash{2}, func() (common.Hash, uint64, string, bool, error) { + return headRoot, 10, "full", false, nil }) require.NoError(t, err) require.Empty(t, ch) From b247661ab9b630430074787e51a053a3357237f7 Mon Sep 17 00:00:00 2001 From: kewei Date: Tue, 25 Aug 2026 21:21:31 +0800 Subject: [PATCH 07/43] cl: serialize Gloas gossip and head events --- cl/beacon/beaconevents/event_notifier.go | 9 +++++ cl/beacon/beaconevents/gloas_events_test.go | 24 ++++++++++++ cl/beacon/builder/client.go | 2 + cl/beacon/builder/client_test.go | 12 +++--- cl/beacon/handler/epbs.go | 12 +++--- cl/phase1/network/services/block_service.go | 30 ++++++++++----- .../network/services/block_service_test.go | 34 +++++++++++++++++ .../services/execution_payload_service.go | 12 +++--- cl/phase1/stages/forkchoice.go | 38 ++++++++++--------- 9 files changed, 131 insertions(+), 42 deletions(-) diff --git a/cl/beacon/beaconevents/event_notifier.go b/cl/beacon/beaconevents/event_notifier.go index 411116bb335..22dab8c3190 100644 --- a/cl/beacon/beaconevents/event_notifier.go +++ b/cl/beacon/beaconevents/event_notifier.go @@ -1,8 +1,17 @@ package beaconevents +import "sync" + type EventEmitter struct { stateFeed *stateFeed // block state feed operationFeed *operationFeed // block operation feed + headEventMu sync.Mutex +} + +func (e *EventEmitter) WithHeadEventLock(fn func()) { + e.headEventMu.Lock() + defer e.headEventMu.Unlock() + fn() } func NewEventEmitter() *EventEmitter { diff --git a/cl/beacon/beaconevents/gloas_events_test.go b/cl/beacon/beaconevents/gloas_events_test.go index 7383060b071..a7d55a373da 100644 --- a/cl/beacon/beaconevents/gloas_events_test.go +++ b/cl/beacon/beaconevents/gloas_events_test.go @@ -174,6 +174,30 @@ func TestGloasEventFeedsDoNotBlockOnSlowSubscriber(t *testing.T) { } } +func TestHeadEventEmissionIsSerialized(t *testing.T) { + emitter := NewEventEmitter() + entered := make(chan struct{}) + release := make(chan struct{}) + second := make(chan struct{}) + go emitter.WithHeadEventLock(func() { + close(entered) + <-release + }) + <-entered + go emitter.WithHeadEventLock(func() { close(second) }) + select { + case <-second: + t.Fatal("second head event entered before the first completed") + case <-time.After(10 * time.Millisecond): + } + close(release) + select { + case <-second: + case <-time.After(time.Second): + t.Fatal("second head event did not progress") + } +} + func TestGloasEventFeeds(t *testing.T) { emitter := NewEventEmitter() stateEvents := make(chan *EventStream, 1) diff --git a/cl/beacon/builder/client.go b/cl/beacon/builder/client.go index c5716db27dd..5e662c78796 100644 --- a/cl/beacon/builder/client.go +++ b/cl/beacon/builder/client.go @@ -418,6 +418,8 @@ func (b *builderClient) builderCall(ctx context.Context, method string, target b var attemptTimeout time.Duration if deadline, ok := ctx.Deadline(); ok && len(target.ips) > 1 { attemptTimeout = time.Until(deadline) / time.Duration(len(target.ips)) + } else if len(target.ips) > 1 { + attemptTimeout = time.Second } requestContext := context.WithValue(ctx, pinnedBuilderTargetKey{}, pinnedBuilderTarget{hostname: target.hostname, ips: target.ips, attemptTimeout: attemptTimeout}) request, err := http.NewRequestWithContext(requestContext, method, target.url, body) diff --git a/cl/beacon/builder/client_test.go b/cl/beacon/builder/client_test.go index 5e08034af61..51dcdfe668e 100644 --- a/cl/beacon/builder/client_test.go +++ b/cl/beacon/builder/client_test.go @@ -618,18 +618,23 @@ func TestDynamicBuilderDialAllFailAndCancellation(t *testing.T) { t.Run("canceled dial stops fallback", func(t *testing.T) { var dialed []string + var dialedMu sync.Mutex ctx, cancel := context.WithCancel(t.Context()) defer cancel() client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{}) client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { return addresses, nil } client.transport = newPinnedBuilderTransport(func(dialCtx context.Context, _, address string) (net.Conn, error) { + dialedMu.Lock() dialed = append(dialed, address) + dialedMu.Unlock() cancel() <-dialCtx.Done() return nil, dialCtx.Err() }) require.ErrorIs(t, client.SubmitBuilderPreferences(ctx, "http://builder.example:18550", common.Bytes48{}, request), context.Canceled) + dialedMu.Lock() + defer dialedMu.Unlock() require.Equal(t, []string{"93.184.216.34:18550"}, dialed) }) } @@ -654,11 +659,8 @@ func TestDynamicBuilderDialBlackholeDoesNotConsumeFallbackDeadline(t *testing.T) }() return clientConn, nil }) - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()} - - require.NoError(t, client.SubmitBuilderPreferences(ctx, "http://builder.example:18550", common.Bytes48{}, request)) + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + require.NoError(t, client.SubmitSignedBeaconBlock(t.Context(), "http://builder.example:18550", block)) } func TestPrivateBuilderTargetsRequireExplicitPolicy(t *testing.T) { diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index fd659c238c5..1329102f928 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -938,11 +938,13 @@ func (a *ApiHandler) emitFullHeadV2(block *cltypes.SignedBeaconBlock, blockRoot if err != nil { return } - currentRoot, currentSlot, err := a.forkchoiceStore.GetHead(nil) - if err != nil || currentRoot != headRoot || currentSlot != headSlot { - return - } - a.emitters.State().SendHeadV2(event) + a.emitters.WithHeadEventLock(func() { + currentRoot, currentSlot, err := a.forkchoiceStore.GetHead(nil) + if err != nil || currentRoot != headRoot || currentSlot != headSlot { + return + } + a.emitters.State().SendHeadV2(event) + }) } func (a *ApiHandler) decodeExecutionPayloadEnvelopeRequest(w http.ResponseWriter, r *http.Request, contentType string, blobDataIncluded bool) (*cltypes.SignedExecutionPayloadEnvelope, *cltypes.SignedExecutionPayloadEnvelopeContents, error) { diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index 3b614dde783..8a969202536 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -64,6 +64,7 @@ type blockService struct { // reference: https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#beacon_block seenBlocksCache *lru.Cache[proposerIndexAndSlot, struct{}] + seenBlocksMu sync.Mutex // blocks that should be scheduled for later execution (e.g missing blobs). emitter *beaconevents.EventEmitter @@ -123,17 +124,9 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp log.Trace("Received block via gossip", "slot", msg.Block.Slot) // [IGNORE] The block is the first block with valid signature received for the proposer for the slot, signed_beacon_block.message.slot. - seenCacheKey := proposerIndexAndSlot{ - proposerIndex: msg.Block.ProposerIndex, - slot: msg.Block.Slot, - } - if b.seenBlocksCache.Contains(seenCacheKey) { - return nil - } - if err := b.validateGossip(ctx, msg, func() { b.scheduleBlockForLaterProcessing(msg) }); err != nil { + if err := b.validateFirstGossip(ctx, msg, func() { b.scheduleBlockForLaterProcessing(msg) }); err != nil { return err } - b.seenBlocksCache.Add(seenCacheKey, struct{}{}) b.publishBlockGossipEvent(msg) // the rest of the validation is done in the forkchoice store if err := b.processAndStoreBlock(ctx, msg); err != nil { @@ -147,7 +140,21 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp } func (b *blockService) ValidateGossip(ctx context.Context, msg *cltypes.SignedBeaconBlock) error { - return b.validateGossip(ctx, msg, nil) + return b.validateFirstGossip(ctx, msg, nil) +} + +func (b *blockService) validateFirstGossip(ctx context.Context, msg *cltypes.SignedBeaconBlock, schedule func()) error { + if err := b.validateGossip(ctx, msg, schedule); err != nil { + return err + } + key := proposerIndexAndSlot{proposerIndex: msg.Block.ProposerIndex, slot: msg.Block.Slot} + b.seenBlocksMu.Lock() + defer b.seenBlocksMu.Unlock() + if b.seenBlocksCache.Contains(key) { + return fmt.Errorf("%w: block already seen for proposer and slot", ErrIgnore) + } + b.seenBlocksCache.Add(key, struct{}{}) + return nil } func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeaconBlock, schedule func()) error { @@ -217,6 +224,9 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac parentIsFull = parentBid != nil && parentBid.Message != nil && gloasBid.ParentBlockHash == parentBid.Message.BlockHash if parentIsFull { status, seen := b.forkchoiceStore.GetRecentExecutionPayloadStatusByRoot(msg.Block.ParentRoot) + if status == execution_client.PayloadStatusInvalidated { + return errors.New("parent execution payload is invalid") + } if !seen || status != execution_client.PayloadStatusValidated { if schedule != nil { schedule() diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index 31a4437b60e..4ccf7ad1fd5 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -20,6 +20,7 @@ import ( "bytes" "context" "errors" + "sync" "testing" "github.com/stretchr/testify/require" @@ -273,6 +274,39 @@ func TestBlockServiceGossipAcceptsVerifiedFullParentPayload(t *testing.T) { require.NoError(t, service.ValidateGossip(t.Context(), child)) } +func TestBlockServiceGossipFirstValidReservationIsAtomic(t *testing.T) { + service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated + errs := make(chan error, 2) + var wg sync.WaitGroup + for range 2 { + wg.Go(func() { errs <- service.ValidateGossip(t.Context(), child) }) + } + wg.Wait() + close(errs) + accepted := 0 + ignored := 0 + for err := range errs { + if err == nil { + accepted++ + } else if errors.Is(err, ErrIgnore) { + ignored++ + } + } + require.Equal(t, 1, accepted) + require.Equal(t, 1, ignored) +} + +func TestBlockServiceGossipRejectsInvalidatedFullParentPayload(t *testing.T) { + service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusInvalidated + scheduled := false + err := service.(*blockService).validateFirstGossip(t.Context(), child, func() { scheduled = true }) + require.Error(t, err) + require.NotErrorIs(t, err, ErrIgnore) + require.False(t, scheduled) +} + func TestBlockServiceGossipRejectsWrongEmptyParentExecutionHead(t *testing.T) { service, child, _, _, _ := newGloasGossipValidationFixture(t, func(common.Hash, common.Hash) common.Hash { return common.Hash{0x99} diff --git a/cl/phase1/network/services/execution_payload_service.go b/cl/phase1/network/services/execution_payload_service.go index fdfd2457bb2..5936f59b53a 100644 --- a/cl/phase1/network/services/execution_payload_service.go +++ b/cl/phase1/network/services/execution_payload_service.go @@ -245,11 +245,13 @@ func (s *executionPayloadService) emitFullHeadUpdate(block *cltypes.SignedBeacon if err != nil { return } - currentHeadRoot, currentHeadSlot, err := s.forkchoiceStore.GetHead(nil) - if err != nil || currentHeadRoot != headRoot || currentHeadSlot != headSlot { - return - } - s.emitters.State().SendHeadV2(headEvent) + s.emitters.WithHeadEventLock(func() { + currentHeadRoot, currentHeadSlot, err := s.forkchoiceStore.GetHead(nil) + if err != nil || currentHeadRoot != headRoot || currentHeadSlot != headSlot { + return + } + s.emitters.State().SendHeadV2(headEvent) + }) } // queuePendingEnvelope adds an envelope to the pending queue for later processing diff --git a/cl/phase1/stages/forkchoice.go b/cl/phase1/stages/forkchoice.go index f4bfcb05434..bb32ab45944 100644 --- a/cl/phase1/stages/forkchoice.go +++ b/cl/phase1/stages/forkchoice.go @@ -248,24 +248,28 @@ func emitHeadEvent(cfg *Cfg, headSlot uint64, headRoot common.Hash, headState *s } func emitHeadEventsIfCurrent(emitter *beaconevents.EventEmitter, headEvent *beaconevents.HeadV2Data, headSlot uint64, headRoot, stateRoot common.Hash, getHead func() (common.Hash, uint64, string, bool, error)) error { - currentRoot, currentSlot, payloadStatus, executionOptimistic, err := getHead() - if err != nil { - return fmt.Errorf("failed to revalidate head event: %w", err) - } - if currentRoot != headRoot || currentSlot != headSlot || payloadStatus != headEvent.Data.PayloadStatus || executionOptimistic != headEvent.Data.ExecutionOptimistic { - return nil - } - emitter.State().SendHead(&beaconevents.HeadData{ - Slot: headSlot, - Block: headRoot, - State: stateRoot, - EpochTransition: true, - PreviousDutyDependentRoot: headEvent.Data.CurrentEpochDependentRoot, - CurrentDutyDependentRoot: headEvent.Data.NextEpochDependentRoot, - ExecutionOptimistic: false, + var validationErr error + emitter.WithHeadEventLock(func() { + currentRoot, currentSlot, payloadStatus, executionOptimistic, err := getHead() + if err != nil { + validationErr = fmt.Errorf("failed to revalidate head event: %w", err) + return + } + if currentRoot != headRoot || currentSlot != headSlot || payloadStatus != headEvent.Data.PayloadStatus || executionOptimistic != headEvent.Data.ExecutionOptimistic { + return + } + emitter.State().SendHead(&beaconevents.HeadData{ + Slot: headSlot, + Block: headRoot, + State: stateRoot, + EpochTransition: true, + PreviousDutyDependentRoot: headEvent.Data.CurrentEpochDependentRoot, + CurrentDutyDependentRoot: headEvent.Data.NextEpochDependentRoot, + ExecutionOptimistic: false, + }) + emitter.State().SendHeadV2(headEvent) }) - emitter.State().SendHeadV2(headEvent) - return nil + return validationErr } func emitNextPaylodAttributesEvent(cfg *Cfg, headSlot uint64, headRoot common.Hash, s *state.CachingBeaconState) error { From da31cca30bd8769124fd53ce24f42474ccf15a7f Mon Sep 17 00:00:00 2001 From: kewei Date: Tue, 25 Aug 2026 21:38:42 +0800 Subject: [PATCH 08/43] cl: roll back failed gossip reservations --- cl/beacon/handler/block_production.go | 23 ++- cl/beacon/handler/block_production_test.go | 78 +++++++++ cl/beacon/handler/epbs.go | 9 +- cl/beacon/handler/utils_test.go | 3 + cl/phase1/network/services/block_service.go | 161 ++++++++++++++++-- .../network/services/block_service_test.go | 142 ++++++++++++++- .../mock_services/block_service_mock.go | 108 ++++++++++++ cl/phase1/network/services/types.go | 3 + 8 files changed, 498 insertions(+), 29 deletions(-) diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 7c00bc7b3c1..117946485c4 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -2105,6 +2105,7 @@ func readBoundedBody(body io.Reader, limit int64) ([]byte, error) { } func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeaconBlock, validation BlockPublishingValidation) error { + releaseGossipReservation := false if validation == BlockPublishingValidationGossip { if a.blockService == nil { return errors.New("block gossip validator unavailable") @@ -2112,6 +2113,12 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac if err := a.blockService.ValidateGossip(ctx, blk); err != nil { return fmt.Errorf("%w: %w", errPublishedBlockValidation, err) } + releaseGossipReservation = true + defer func() { + if releaseGossipReservation { + a.blockService.ReleaseGossipReservation(blk) + } + }() } blkSSZ, err := blk.EncodeSSZ(nil) if err != nil { @@ -2219,12 +2226,6 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac if err := store(ctx); err != nil { return err } - } else { - go func() { - if err := retryPublishedBlockStore(context.Background(), 3, 100*time.Millisecond, store); err != nil { - log.Error("BlockPublishing: Failed to store block and blobs", "err", err) - } - }() } lenBlobs := 0 @@ -2245,6 +2246,16 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac if err := a.publishGossip(ctx, gossip.TopicNameBeaconBlock, blkSSZ); err != nil { return err } + if validation == BlockPublishingValidationGossip { + a.blockService.CommitGossipReservation(blk) + releaseGossipReservation = false + go func() { + if err := retryPublishedBlockStore(context.Background(), 3, 100*time.Millisecond, store); err != nil { + log.Error("BlockPublishing: Failed to store block and blobs", "err", err) + a.blockService.ScheduleBlockForLaterProcessing(blk) + } + }() + } if blk.Version() < clparams.FuluVersion { for idx, blob := range blobsSidecarsBytes { diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index a367e55405e..ceeb5815d68 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -402,6 +402,84 @@ func TestBroadcastBlockRunsGossipValidationBeforePublishing(t *testing.T) { require.ErrorContains(t, err, validationErr.Error()) } +func TestBroadcastBlockReleasesGossipReservationAfterPreparationFailure(t *testing.T) { + ctrl := gomock.NewController(t) + blockService := network_services_mock.NewMockBlockService(ctrl) + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) + block.Block.Body.BlobKzgCommitments.Append(&cltypes.KZGCommitment{1}) + blobBundles, err := lru.New[common.Bytes48, BlobBundle]("test-blobs", 1) + require.NoError(t, err) + blockService.EXPECT().ValidateGossip(gomock.Any(), block).Return(nil) + blockService.EXPECT().ReleaseGossipReservation(block) + + err = (&ApiHandler{blockService: blockService, blobBundles: blobBundles}).broadcastBlock(t.Context(), block, BlockPublishingValidationGossip) + require.ErrorContains(t, err, "missing blob bundle") +} + +func TestBroadcastBlockDoesNotStoreBeforeBlockPublication(t *testing.T) { + ctrl := gomock.NewController(t) + _, blocks, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + block := blocks[1] + blockService := network_services_mock.NewMockBlockService(ctrl) + blockService.EXPECT().ValidateGossip(gomock.Any(), block).Return(nil) + blockService.EXPECT().ReleaseGossipReservation(block) + handler.blockService = blockService + storeCalled := make(chan struct{}) + var closeStoreCalled sync.Once + blobStorage := blob_storage_mock.NewMockBlobStorage(ctrl) + blobStorage.EXPECT().WriteBlobSidecars(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(context.Context, common.Hash, []*cltypes.BlobSidecar) error { + closeStoreCalled.Do(func() { close(storeCalled) }) + return nil + }).AnyTimes() + handler.blobStoage = blobStorage + gossipManager := gossip_mock.NewMockGossip(ctrl) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, gomock.Any()).Return(errors.New("block unavailable")) + handler.gossipManager = gossipManager + + err := handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip) + require.ErrorContains(t, err, "block unavailable") + select { + case <-storeCalled: + t.Fatal("block storage started before beacon block publication succeeded") + case <-time.After(50 * time.Millisecond): + } +} + +func TestBroadcastBlockKeepsGossipReservationAfterBlockPublication(t *testing.T) { + ctrl := gomock.NewController(t) + _, blocks, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + block := blocks[1] + commitment := &cltypes.KZGCommitment{1} + block.Block.Body.BlobKzgCommitments = solid.NewStaticListSSZ[*cltypes.KZGCommitment](1, 48) + block.Block.Body.BlobKzgCommitments.Append(commitment) + handler.blobBundles.Add(common.Bytes48(*commitment), BlobBundle{ + Commitment: common.Bytes48(*commitment), + Blob: &cltypes.Blob{}, + KzgProofs: []common.Bytes48{{1}}, + }) + blockService := network_services_mock.NewMockBlockService(ctrl) + blockService.EXPECT().ValidateGossip(gomock.Any(), block).Return(nil) + blockService.EXPECT().CommitGossipReservation(block) + scheduled := make(chan struct{}) + blockService.EXPECT().ScheduleBlockForLaterProcessing(block).Do(func(*cltypes.SignedBeaconBlock) { close(scheduled) }) + handler.blockService = blockService + blobStorage := blob_storage_mock.NewMockBlobStorage(ctrl) + blobStorage.EXPECT().WriteBlobSidecars(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("storage unavailable")).AnyTimes() + handler.blobStoage = blobStorage + gossipManager := gossip_mock.NewMockGossip(ctrl) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, gomock.Any()).Return(nil) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBlobSidecar(uint64(0)), gomock.Any()).Return(errors.New("sidecar unavailable")) + handler.gossipManager = gossipManager + + err := handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip) + require.ErrorContains(t, err, "sidecar unavailable") + select { + case <-scheduled: + case <-time.After(time.Second): + t.Fatal("block was not scheduled after terminal local storage failure") + } +} + func TestPublishGossipReturnsPublishFailure(t *testing.T) { ctrl := gomock.NewController(t) gossipManager := gossip_mock.NewMockGossip(ctrl) diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index 1329102f928..e0546c71417 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -862,17 +862,18 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) return } - if errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) { + switch { + case errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable): a.logger.Debug("[Beacon REST] OnExecutionPayload queued or ignored", "err", err) status = http.StatusAccepted gossipValidated = errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) - } else if canonical && validation == BlockPublishingValidationGossip { + case canonical && validation == BlockPublishingValidationGossip: status = http.StatusAccepted gossipValidated = true - } else if canonical { + case canonical: beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) return - } else { + default: beaconhttp.WrapEndpointError(err).WriteTo(w) return } diff --git a/cl/beacon/handler/utils_test.go b/cl/beacon/handler/utils_test.go index a437522eace..b7525a3473b 100644 --- a/cl/beacon/handler/utils_test.go +++ b/cl/beacon/handler/utils_test.go @@ -129,6 +129,9 @@ func setupTestingHandler(t *testing.T, v clparams.StateVersion, logger log.Logge proposerSlashingService := mock_services.NewMockProposerSlashingService(ctrl) blockService := mock_services.NewMockBlockService(ctrl) blockService.EXPECT().ValidateGossip(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + blockService.EXPECT().CommitGossipReservation(gomock.Any()).AnyTimes() + blockService.EXPECT().ReleaseGossipReservation(gomock.Any()).AnyTimes() + blockService.EXPECT().ScheduleBlockForLaterProcessing(gomock.Any()).AnyTimes() // ctx context.Context, subnetID *uint64, msg *cltypes.SyncCommitteeMessage) error syncCommitteeMessagesService.EXPECT().ProcessMessage(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, subnetID *uint64, msg *services.SyncCommitteeMessageForGossip) error { diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index 8a969202536..291487ed206 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -56,6 +56,12 @@ type blockJob struct { creationTime time.Time } +type blockReservation struct { + pending chan struct{} + version uint64 + validators uint64 +} + type blockService struct { forkchoiceStore forkchoice.ForkChoiceStorage syncedData *synced_data.SyncedDataManager @@ -64,6 +70,7 @@ type blockService struct { // reference: https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#beacon_block seenBlocksCache *lru.Cache[proposerIndexAndSlot, struct{}] + reservations map[proposerIndexAndSlot]*blockReservation seenBlocksMu sync.Mutex // blocks that should be scheduled for later execution (e.g missing blobs). @@ -93,6 +100,7 @@ func NewBlockService( ethClock: ethClock, beaconCfg: beaconCfg, seenBlocksCache: seenBlocksCache, + reservations: make(map[proposerIndexAndSlot]*blockReservation), emitter: emitter, db: db, } @@ -124,14 +132,14 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp log.Trace("Received block via gossip", "slot", msg.Block.Slot) // [IGNORE] The block is the first block with valid signature received for the proposer for the slot, signed_beacon_block.message.slot. - if err := b.validateFirstGossip(ctx, msg, func() { b.scheduleBlockForLaterProcessing(msg) }); err != nil { + if err := b.validateFirstGossip(ctx, msg, func() { b.ScheduleBlockForLaterProcessing(msg) }, true); err != nil { return err } b.publishBlockGossipEvent(msg) // the rest of the validation is done in the forkchoice store if err := b.processAndStoreBlock(ctx, msg); err != nil { if errors.Is(err, forkchoice.ErrEIP4844DataNotAvailable) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) || errors.Is(err, forkchoice.ErrParentEnvelopePending) { - b.scheduleBlockForLaterProcessing(msg) + b.ScheduleBlockForLaterProcessing(msg) return nil } return err @@ -140,23 +148,151 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp } func (b *blockService) ValidateGossip(ctx context.Context, msg *cltypes.SignedBeaconBlock) error { - return b.validateFirstGossip(ctx, msg, nil) + if err := b.validateGossip(ctx, msg, nil); err != nil { + return err + } + return b.reserveGossipKey(blockGossipKey(msg)) } -func (b *blockService) validateFirstGossip(ctx context.Context, msg *cltypes.SignedBeaconBlock, schedule func()) error { - if err := b.validateGossip(ctx, msg, schedule); err != nil { - return err +func (b *blockService) CommitGossipReservation(msg *cltypes.SignedBeaconBlock) { + if msg == nil || msg.Block == nil { + return } - key := proposerIndexAndSlot{proposerIndex: msg.Block.ProposerIndex, slot: msg.Block.Slot} + b.commitGossipKey(blockGossipKey(msg)) +} + +func (b *blockService) ReleaseGossipReservation(msg *cltypes.SignedBeaconBlock) { + if msg == nil || msg.Block == nil { + return + } + b.releaseGossipKey(blockGossipKey(msg)) +} + +func (b *blockService) validateFirstGossip(ctx context.Context, msg *cltypes.SignedBeaconBlock, schedule func(), waitForPending bool) error { + key := blockGossipKey(msg) + for { + if err := ctx.Err(); err != nil { + return fmt.Errorf("%w: block validation canceled: %w", ErrIgnore, err) + } + b.seenBlocksMu.Lock() + if b.seenBlocksCache.Contains(key) { + b.seenBlocksMu.Unlock() + return fmt.Errorf("%w: block already seen for proposer and slot", ErrIgnore) + } + reservation := b.reservations[key] + if reservation != nil && reservation.pending != nil { + done := reservation.pending + b.seenBlocksMu.Unlock() + if !waitForPending { + return fmt.Errorf("%w: block reservation pending for proposer and slot", ErrIgnore) + } + select { + case <-ctx.Done(): + return fmt.Errorf("%w: block reservation pending: %w", ErrIgnore, ctx.Err()) + case <-done: + } + b.seenBlocksMu.Lock() + committed := b.seenBlocksCache.Contains(key) + b.seenBlocksMu.Unlock() + if committed { + return fmt.Errorf("%w: block already seen for proposer and slot", ErrIgnore) + } + continue + } + if reservation == nil { + reservation = &blockReservation{} + b.reservations[key] = reservation + } + reservationVersion := reservation.version + reservation.validators++ + b.seenBlocksMu.Unlock() + + validationErr := b.validateGossip(ctx, msg, schedule) + + b.seenBlocksMu.Lock() + reservation.validators-- + if err := ctx.Err(); err != nil { + b.cleanupReservationLocked(key, reservation) + b.seenBlocksMu.Unlock() + return fmt.Errorf("%w: block validation canceled: %w", ErrIgnore, err) + } + if reservation.version != reservationVersion { + b.cleanupReservationLocked(key, reservation) + b.seenBlocksMu.Unlock() + continue + } + if validationErr != nil { + b.cleanupReservationLocked(key, reservation) + b.seenBlocksMu.Unlock() + return validationErr + } + if b.seenBlocksCache.Contains(key) || reservation.pending != nil { + b.cleanupReservationLocked(key, reservation) + b.seenBlocksMu.Unlock() + continue + } + b.seenBlocksCache.Add(key, struct{}{}) + b.cleanupReservationLocked(key, reservation) + b.seenBlocksMu.Unlock() + return nil + } +} + +func (b *blockService) reserveGossipKey(key proposerIndexAndSlot) error { b.seenBlocksMu.Lock() defer b.seenBlocksMu.Unlock() - if b.seenBlocksCache.Contains(key) { + reservation := b.reservations[key] + if b.seenBlocksCache.Contains(key) || reservation != nil && reservation.pending != nil { return fmt.Errorf("%w: block already seen for proposer and slot", ErrIgnore) } - b.seenBlocksCache.Add(key, struct{}{}) + if reservation == nil { + reservation = &blockReservation{} + b.reservations[key] = reservation + } + reservation.pending = make(chan struct{}) + reservation.version++ return nil } +func (b *blockService) commitGossipKey(key proposerIndexAndSlot) { + b.seenBlocksMu.Lock() + defer b.seenBlocksMu.Unlock() + reservation := b.reservations[key] + if reservation == nil || reservation.pending == nil { + return + } + done := reservation.pending + reservation.pending = nil + reservation.version++ + b.seenBlocksCache.Add(key, struct{}{}) + close(done) + b.cleanupReservationLocked(key, reservation) +} + +func (b *blockService) releaseGossipKey(key proposerIndexAndSlot) { + b.seenBlocksMu.Lock() + defer b.seenBlocksMu.Unlock() + reservation := b.reservations[key] + if reservation == nil || reservation.pending == nil { + return + } + done := reservation.pending + reservation.pending = nil + reservation.version++ + close(done) + b.cleanupReservationLocked(key, reservation) +} + +func (b *blockService) cleanupReservationLocked(key proposerIndexAndSlot, reservation *blockReservation) { + if reservation.pending == nil && reservation.validators == 0 && b.reservations[key] == reservation { + delete(b.reservations, key) + } +} + +func blockGossipKey(msg *cltypes.SignedBeaconBlock) proposerIndexAndSlot { + return proposerIndexAndSlot{proposerIndex: msg.Block.ProposerIndex, slot: msg.Block.Slot} +} + func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeaconBlock, schedule func()) error { if msg == nil || msg.Block == nil || msg.Block.Body == nil { return errors.New("missing beacon block") @@ -224,9 +360,6 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac parentIsFull = parentBid != nil && parentBid.Message != nil && gloasBid.ParentBlockHash == parentBid.Message.BlockHash if parentIsFull { status, seen := b.forkchoiceStore.GetRecentExecutionPayloadStatusByRoot(msg.Block.ParentRoot) - if status == execution_client.PayloadStatusInvalidated { - return errors.New("parent execution payload is invalid") - } if !seen || status != execution_client.PayloadStatusValidated { if schedule != nil { schedule() @@ -312,8 +445,8 @@ func (b *blockService) publishBlockGossipEvent(block *cltypes.SignedBeaconBlock) }) } -// scheduleBlockForLaterProcessing schedules a block for later processing -func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeaconBlock) { +// ScheduleBlockForLaterProcessing schedules a block for later processing. +func (b *blockService) ScheduleBlockForLaterProcessing(block *cltypes.SignedBeaconBlock) { // [Modified in Gloas:EIP7732] ExecutionPayload is not in block.body for GLOAS var blockNum uint64 if block.Block.Body.ExecutionPayload != nil { diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index 4ccf7ad1fd5..f7e2d446114 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -22,6 +22,7 @@ import ( "errors" "sync" "testing" + "time" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -297,14 +298,145 @@ func TestBlockServiceGossipFirstValidReservationIsAtomic(t *testing.T) { require.Equal(t, 1, ignored) } -func TestBlockServiceGossipRejectsInvalidatedFullParentPayload(t *testing.T) { +func TestBlockServiceGossipReservationCanBeReleased(t *testing.T) { + service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated + require.NoError(t, service.ValidateGossip(t.Context(), child)) + service.ReleaseGossipReservation(child) + require.NoError(t, service.ValidateGossip(t.Context(), child)) +} + +func TestBlockServicePendingGossipReservationHandsOffToP2P(t *testing.T) { + service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated + require.NoError(t, service.ValidateGossip(t.Context(), child)) + errCh := make(chan error, 1) + go func() { + errCh <- service.(*blockService).validateFirstGossip(t.Context(), child, nil, true) + }() + select { + case err := <-errCh: + t.Fatalf("P2P validation returned before REST reservation resolved: %v", err) + case <-time.After(20 * time.Millisecond): + } + service.ReleaseGossipReservation(child) + require.NoError(t, <-errCh) +} + +func TestBlockServiceCommittedGossipReservationRejectsWaitingP2P(t *testing.T) { + service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated + require.NoError(t, service.ValidateGossip(t.Context(), child)) + errCh := make(chan error, 1) + go func() { + errCh <- service.(*blockService).validateFirstGossip(t.Context(), child, nil, true) + }() + select { + case err := <-errCh: + t.Fatalf("P2P validation returned before REST reservation resolved: %v", err) + case <-time.After(20 * time.Millisecond): + } + service.CommitGossipReservation(child) + require.ErrorIs(t, <-errCh, ErrIgnore) +} + +func TestBlockServiceRevalidatesP2PAfterReservationRelease(t *testing.T) { + service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated + require.NoError(t, service.ValidateGossip(t.Context(), child)) + type result struct { + err error + scheduled bool + } + resultCh := make(chan result, 1) + go func() { + scheduled := false + err := service.(*blockService).validateFirstGossip(t.Context(), child, func() { scheduled = true }, true) + resultCh <- result{err: err, scheduled: scheduled} + }() + select { + case got := <-resultCh: + t.Fatalf("P2P validation returned before REST reservation resolved: %v", got.err) + case <-time.After(20 * time.Millisecond): + } + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusInvalidated + service.ReleaseGossipReservation(child) + got := <-resultCh + require.ErrorIs(t, got.err, ErrIgnore) + require.True(t, got.scheduled) +} + +func TestBlockServiceUnrelatedReservationDoesNotRevalidateP2P(t *testing.T) { + service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated + parentState := fcu.StateAtBlockRootVal[parentRoot] + validationEntered := make(chan struct{}) + finishValidation := make(chan struct{}) + validationCalls := 0 + fcu.GetStateAtBlockRootFn = func(root common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) { + require.Equal(t, parentRoot, root) + require.True(t, alwaysCopy) + validationCalls++ + if validationCalls == 1 { + close(validationEntered) + <-finishValidation + } + return parentState.Copy() + } + errCh := make(chan error, 1) + go func() { + errCh <- service.(*blockService).validateFirstGossip(t.Context(), child, nil, true) + }() + <-validationEntered + otherKey := proposerIndexAndSlot{proposerIndex: child.Block.ProposerIndex + 1, slot: child.Block.Slot} + require.NoError(t, service.(*blockService).reserveGossipKey(otherKey)) + service.(*blockService).releaseGossipKey(otherKey) + close(finishValidation) + require.NoError(t, <-errCh) + require.Equal(t, 1, validationCalls) +} + +func TestBlockServiceCanceledHandoffDoesNotClaimSeen(t *testing.T) { + service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated + parentState := fcu.StateAtBlockRootVal[parentRoot] + revalidationEntered := make(chan struct{}) + finishRevalidation := make(chan struct{}) + require.NoError(t, service.ValidateGossip(t.Context(), child)) + fcu.GetStateAtBlockRootFn = func(root common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) { + close(revalidationEntered) + <-finishRevalidation + return parentState.Copy() + } + ctx, cancel := context.WithCancel(t.Context()) + errCh := make(chan error, 1) + go func() { + errCh <- service.(*blockService).validateFirstGossip(ctx, child, nil, true) + }() + service.ReleaseGossipReservation(child) + <-revalidationEntered + cancel() + close(finishRevalidation) + require.ErrorIs(t, <-errCh, ErrIgnore) + key := blockGossipKey(child) + blockService := service.(*blockService) + blockService.seenBlocksMu.Lock() + require.False(t, blockService.seenBlocksCache.Contains(key)) + require.NotContains(t, blockService.reservations, key) + blockService.seenBlocksMu.Unlock() + fcu.GetStateAtBlockRootFn = func(common.Hash, bool) (*state.CachingBeaconState, error) { + return parentState.Copy() + } + require.NoError(t, blockService.validateFirstGossip(t.Context(), child, nil, true)) +} + +func TestBlockServiceGossipIgnoresInvalidatedFullParentPayload(t *testing.T) { service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusInvalidated scheduled := false - err := service.(*blockService).validateFirstGossip(t.Context(), child, func() { scheduled = true }) - require.Error(t, err) - require.NotErrorIs(t, err, ErrIgnore) - require.False(t, scheduled) + err := service.(*blockService).validateFirstGossip(t.Context(), child, func() { scheduled = true }, false) + require.ErrorIs(t, err, ErrIgnore) + require.True(t, scheduled) } func TestBlockServiceGossipRejectsWrongEmptyParentExecutionHead(t *testing.T) { diff --git a/cl/phase1/network/services/mock_services/block_service_mock.go b/cl/phase1/network/services/mock_services/block_service_mock.go index f55b32dc9ef..eb33748b881 100644 --- a/cl/phase1/network/services/mock_services/block_service_mock.go +++ b/cl/phase1/network/services/mock_services/block_service_mock.go @@ -43,6 +43,42 @@ func (m *MockBlockService) EXPECT() *MockBlockServiceMockRecorder { return m.recorder } +// CommitGossipReservation mocks base method. +func (m *MockBlockService) CommitGossipReservation(arg0 *cltypes.SignedBeaconBlock) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "CommitGossipReservation", arg0) +} + +// CommitGossipReservation indicates an expected call of CommitGossipReservation. +func (mr *MockBlockServiceMockRecorder) CommitGossipReservation(arg0 any) *MockBlockServiceCommitGossipReservationCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommitGossipReservation", reflect.TypeOf((*MockBlockService)(nil).CommitGossipReservation), arg0) + return &MockBlockServiceCommitGossipReservationCall{Call: call} +} + +// MockBlockServiceCommitGossipReservationCall wrap *gomock.Call +type MockBlockServiceCommitGossipReservationCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockBlockServiceCommitGossipReservationCall) Return() *MockBlockServiceCommitGossipReservationCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockBlockServiceCommitGossipReservationCall) Do(f func(*cltypes.SignedBeaconBlock)) *MockBlockServiceCommitGossipReservationCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockBlockServiceCommitGossipReservationCall) DoAndReturn(f func(*cltypes.SignedBeaconBlock)) *MockBlockServiceCommitGossipReservationCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // DecodeGossipMessage mocks base method. func (m *MockBlockService) DecodeGossipMessage(pid peer.ID, data []byte, version clparams.StateVersion) (*cltypes.SignedBeaconBlock, error) { m.ctrl.T.Helper() @@ -158,6 +194,78 @@ func (c *MockBlockServiceProcessMessageCall) DoAndReturn(f func(context.Context, return c } +// ReleaseGossipReservation mocks base method. +func (m *MockBlockService) ReleaseGossipReservation(arg0 *cltypes.SignedBeaconBlock) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "ReleaseGossipReservation", arg0) +} + +// ReleaseGossipReservation indicates an expected call of ReleaseGossipReservation. +func (mr *MockBlockServiceMockRecorder) ReleaseGossipReservation(arg0 any) *MockBlockServiceReleaseGossipReservationCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReleaseGossipReservation", reflect.TypeOf((*MockBlockService)(nil).ReleaseGossipReservation), arg0) + return &MockBlockServiceReleaseGossipReservationCall{Call: call} +} + +// MockBlockServiceReleaseGossipReservationCall wrap *gomock.Call +type MockBlockServiceReleaseGossipReservationCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockBlockServiceReleaseGossipReservationCall) Return() *MockBlockServiceReleaseGossipReservationCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockBlockServiceReleaseGossipReservationCall) Do(f func(*cltypes.SignedBeaconBlock)) *MockBlockServiceReleaseGossipReservationCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockBlockServiceReleaseGossipReservationCall) DoAndReturn(f func(*cltypes.SignedBeaconBlock)) *MockBlockServiceReleaseGossipReservationCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ScheduleBlockForLaterProcessing mocks base method. +func (m *MockBlockService) ScheduleBlockForLaterProcessing(arg0 *cltypes.SignedBeaconBlock) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "ScheduleBlockForLaterProcessing", arg0) +} + +// ScheduleBlockForLaterProcessing indicates an expected call of ScheduleBlockForLaterProcessing. +func (mr *MockBlockServiceMockRecorder) ScheduleBlockForLaterProcessing(arg0 any) *MockBlockServiceScheduleBlockForLaterProcessingCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScheduleBlockForLaterProcessing", reflect.TypeOf((*MockBlockService)(nil).ScheduleBlockForLaterProcessing), arg0) + return &MockBlockServiceScheduleBlockForLaterProcessingCall{Call: call} +} + +// MockBlockServiceScheduleBlockForLaterProcessingCall wrap *gomock.Call +type MockBlockServiceScheduleBlockForLaterProcessingCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockBlockServiceScheduleBlockForLaterProcessingCall) Return() *MockBlockServiceScheduleBlockForLaterProcessingCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockBlockServiceScheduleBlockForLaterProcessingCall) Do(f func(*cltypes.SignedBeaconBlock)) *MockBlockServiceScheduleBlockForLaterProcessingCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockBlockServiceScheduleBlockForLaterProcessingCall) DoAndReturn(f func(*cltypes.SignedBeaconBlock)) *MockBlockServiceScheduleBlockForLaterProcessingCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // ValidateGossip mocks base method. func (m *MockBlockService) ValidateGossip(arg0 context.Context, arg1 *cltypes.SignedBeaconBlock) error { m.ctrl.T.Helper() diff --git a/cl/phase1/network/services/types.go b/cl/phase1/network/services/types.go index 5b0f20c1199..d3e046ee18c 100644 --- a/cl/phase1/network/services/types.go +++ b/cl/phase1/network/services/types.go @@ -11,6 +11,9 @@ import ( type BlockService interface { serviceinterface.Service[*cltypes.SignedBeaconBlock] ValidateGossip(context.Context, *cltypes.SignedBeaconBlock) error + CommitGossipReservation(*cltypes.SignedBeaconBlock) + ReleaseGossipReservation(*cltypes.SignedBeaconBlock) + ScheduleBlockForLaterProcessing(*cltypes.SignedBeaconBlock) } //go:generate mockgen -typed=true -destination=./mock_services/blob_sidecars_service_mock.go -package=mock_services . BlobSidecarsService From 94fb3ec04425fc634868be3f7628b5b3e39dfe85 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 01:09:26 +0800 Subject: [PATCH 09/43] cl/beacon: complete Gloas builder API flow --- cl/beacon/builder/client.go | 9 +- cl/beacon/builder/client_test.go | 12 ++ cl/beacon/handler/block_production.go | 80 +++++--- cl/beacon/handler/block_production_test.go | 176 ++++++++++++++++++ cl/beacon/handler/handler.go | 9 +- .../mock_services/forkchoice_mock.go | 5 + 6 files changed, 265 insertions(+), 26 deletions(-) diff --git a/cl/beacon/builder/client.go b/cl/beacon/builder/client.go index 5e662c78796..a62c52b237c 100644 --- a/cl/beacon/builder/client.go +++ b/cl/beacon/builder/client.go @@ -49,6 +49,7 @@ const ( maxBuilderResponseBodySize = 1 << 20 maxBuilderErrorBodySize = 256 builderPreferencesTimeout = time.Second + builderBeaconBlockTimeout = time.Second defaultBuilderCallLimit = 32 ) @@ -334,15 +335,17 @@ func (b *builderClient) SubmitSignedBeaconBlock(ctx context.Context, builderURL if err != nil { return err } - if err := b.builderAdmission().Acquire(ctx, 1); err != nil { + requestContext, cancel := context.WithTimeout(ctx, builderBeaconBlockTimeout) + defer cancel() + if err := b.builderAdmission().Acquire(requestContext, 1); err != nil { return err } defer b.builderAdmission().Release(1) - target, err := b.builderEndpoint(ctx, BuilderTargetPolicy{}, builderURL, "eth", "v1", "builder", "beacon_blocks") + target, err := b.builderEndpoint(requestContext, BuilderTargetPolicy{}, builderURL, "eth", "v1", "builder", "beacon_blocks") if err != nil { return err } - response, err := b.builderCall(ctx, http.MethodPost, target, map[string]string{ + response, err := b.builderCall(requestContext, http.MethodPost, target, map[string]string{ "Eth-Consensus-Version": block.Version().String(), }, bytes.NewReader(payload)) if err != nil { diff --git a/cl/beacon/builder/client_test.go b/cl/beacon/builder/client_test.go index 51dcdfe668e..65aad0d19c6 100644 --- a/cl/beacon/builder/client_test.go +++ b/cl/beacon/builder/client_test.go @@ -696,6 +696,18 @@ func TestSubmitSignedBeaconBlock(t *testing.T) { require.NoError(t, client.SubmitSignedBeaconBlock(context.Background(), "https://builder.example", block)) } +func TestSubmitSignedBeaconBlockHasHotPathDeadline(t *testing.T) { + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { + deadline, ok := r.Context().Deadline() + require.True(t, ok) + require.LessOrEqual(t, time.Until(deadline), builderBeaconBlockTimeout) + return nil, errors.New("builder unavailable") + })) + + require.Error(t, client.SubmitSignedBeaconBlock(context.Background(), "https://builder.example", block)) +} + func validBuilderRequestAuth() *cltypes.SignedBuilderRequestAuth { return &cltypes.SignedBuilderRequestAuth{ Message: &cltypes.BuilderRequestAuth{Data: []byte("builder-auth"), Slot: 12}, diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 117946485c4..4513211e2bc 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -798,7 +798,7 @@ func (a *ApiHandler) GetEthV3ValidatorBlock( if options := gloasBlockOptionsFromContext(ctx); options != nil && options.selectedBuilderURL != "" { w.Header().Set("Eth-Builder-Url", options.selectedBuilderURL) if root, err := block.ToExecution().Block.HashSSZ(); err == nil { - a.builderRoutes.Add(root, options.selectedBuilderURL) + a.builderRoutes.Add(root, &builderRoute{url: options.selectedBuilderURL}) } } @@ -881,10 +881,13 @@ func (a *ApiHandler) produceBlock( // wait for both tasks to finish wg.Wait() - if localErr != nil { - // if we failed to locally produce the beacon body, we should not proceed with the block production + if localErr != nil && (stateVersion.Before(clparams.GloasVersion) || beaconBody == nil) { return nil, localErr } + if localErr != nil { + blobs = nil + kzgProofs = nil + } // prepare basic block // Always use the post-ProcessSlots state to get the proposer index. // The state has been advanced to targetSlot, so GetBeaconProposerIndex @@ -907,28 +910,30 @@ func (a *ApiHandler) produceBlock( // 3. GLOAS: MEV-Boost blinded blocks not supported; builders use ePBS gossip bids // GLOAS: check p2p and configured Builder API bids against the local value. - if stateVersion.AfterOrEqual(clparams.GloasVersion) && a.epbsPool != nil { + if stateVersion.AfterOrEqual(clparams.GloasVersion) { selfBid := beaconBody.SignedExecutionPayloadBid.Message options := gloasBlockOptionsFromContext(ctx) p2pMinBid := uint64(0) - if options != nil { + if options != nil && options.builderConfig != nil { p2pMinBid = options.builderConfig.MinBid } candidates := make([]gloasBidCandidate, 0, 1) - bidKey := pool.HighestBidKey{ - Slot: targetSlot, - ParentBlockHash: selfBid.ParentBlockHash, - ParentBlockRoot: selfBid.ParentBlockRoot, - } - if externalBid, found := a.epbsPool.HighestBids.Get(bidKey); found { - candidates = append(candidates, gloasBidCandidate{ - bid: externalBid, - boostFactor: boostFactor, - maxExecutionPayment: math.MaxUint64, - minBid: p2pMinBid, - }) + if a.epbsPool != nil { + bidKey := pool.HighestBidKey{ + Slot: targetSlot, + ParentBlockHash: selfBid.ParentBlockHash, + ParentBlockRoot: selfBid.ParentBlockRoot, + } + if externalBid, found := a.epbsPool.HighestBids.Get(bidKey); found { + candidates = append(candidates, gloasBidCandidate{ + bid: externalBid, + boostFactor: boostFactor, + maxExecutionPayment: math.MaxUint64, + minBid: p2pMinBid, + }) + } } - if options != nil && a.builderClient != nil { + if options != nil && options.builderConfig != nil && a.builderClient != nil { proposerPubkey, pubkeyErr := baseState.ValidatorPublicKey(int(proposerIndex)) if pubkeyErr == nil { candidates = append(candidates, a.requestConfiguredBuilderBids(ctx, baseState, targetSlot, proposerPubkey, selfBid, options.builderConfig.Builders)...) @@ -951,6 +956,9 @@ func (a *ApiHandler) produceBlock( } return block, nil } + if localErr != nil { + return nil, localErr + } } block.BeaconBody = beaconBody @@ -1278,6 +1286,13 @@ func (a *ApiHandler) produceBeaconBody( } else { head = baseState.GetLatestBlockHash() } + pendingBid := beaconBody.SignedExecutionPayloadBid + if pendingBid == nil || pendingBid.Message == nil { + return nil, nil, errors.New("produceBeaconBody: missing Gloas execution payload bid") + } + pendingBid.Message.Slot = targetSlot + pendingBid.Message.ParentBlockRoot = baseBlockRoot + pendingBid.Message.ParentBlockHash = head } finalizedHash := a.forkchoiceStore.GetFinalizedExecutionHash(baseState.FinalizedCheckpoint().Root) if finalizedHash == (common.Hash{}) { @@ -1556,12 +1571,15 @@ func (a *ApiHandler) produceBeaconBody( }) } wg.Wait() - if executionErr != nil { - return nil, nil, executionErr - } if syncAggregateErr != nil { return nil, nil, syncAggregateErr } + if executionErr != nil { + if stateVersion.AfterOrEqual(clparams.GloasVersion) { + return beaconBody, nil, executionErr + } + return nil, nil, executionErr + } if executionPayload == nil { return nil, nil, errors.New("failed to produce execution payload") } @@ -1786,9 +1804,29 @@ func (a *ApiHandler) postBeaconBlocks(w http.ResponseWriter, r *http.Request, ap } return nil, beaconhttp.NewEndpointError(http.StatusInternalServerError, err) } + a.forwardPublishedBlockToBuilder(r.Header.Get("Eth-Builder-Url"), block.SignedBlock) return newBeaconResponse(nil), nil } +func (a *ApiHandler) forwardPublishedBlockToBuilder(builderURL string, block *cltypes.SignedBeaconBlock) { + if builderURL == "" || block == nil || block.Block == nil || block.Version() < clparams.GloasVersion || a.builderClient == nil || a.builderRoutes == nil { + return + } + root, err := block.Block.HashSSZ() + if err != nil { + return + } + route, ok := a.builderRoutes.Get(root) + if !ok || route == nil || route.url != builderURL || !route.forwarded.CompareAndSwap(false, true) { + return + } + go func() { + if err := a.builderClient.SubmitSignedBeaconBlock(context.Background(), builderURL, block); err != nil { + a.logger.Warn("Failed to forward signed block to builder", "err", err) + } + }() +} + func (a *ApiHandler) PostEthV1BlindedBlocks(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { resp, err := a.publishBlindedBlocks(w, r, 1) if err != nil { diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index ceeb5815d68..6f4ebcb5dc4 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -32,6 +32,7 @@ import ( "time" "github.com/holiman/uint256" + libp2ppeer "github.com/libp2p/go-libp2p/core/peer" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -48,6 +49,7 @@ import ( "github.com/erigontech/erigon/cl/phase1/forkchoice" gossip_mock "github.com/erigontech/erigon/cl/phase1/network/gossip/mock_services" network_services_mock "github.com/erigontech/erigon/cl/phase1/network/services/mock_services" + serviceinterface "github.com/erigontech/erigon/cl/phase1/network/services/service_interface" sync_pool_mock "github.com/erigontech/erigon/cl/validator/sync_contribution_pool/mock_services" "github.com/erigontech/erigon/cl/validator/validator_params" "github.com/erigontech/erigon/common" @@ -65,6 +67,24 @@ import ( "github.com/erigontech/erigon/node/gointerfaces/typesproto" ) +var _ serviceinterface.Service[*cltypes.SignedExecutionPayloadBid] = acceptingExecutionPayloadBidService{} + +type acceptingExecutionPayloadBidService struct{} + +func (acceptingExecutionPayloadBidService) Names() []string { return nil } + +func (acceptingExecutionPayloadBidService) DecodeGossipMessage(libp2ppeer.ID, []byte, clparams.StateVersion) (*cltypes.SignedExecutionPayloadBid, error) { + return nil, nil +} + +func (acceptingExecutionPayloadBidService) ProcessMessage(context.Context, *uint64, *cltypes.SignedExecutionPayloadBid) error { + return nil +} + +func (acceptingExecutionPayloadBidService) ValidateBid(context.Context, *cltypes.SignedExecutionPayloadBid) error { + return nil +} + func TestStoreDataColumnSidecars(t *testing.T) { ctrl := gomock.NewController(t) storage := blob_storage_mock.NewMockDataColumnStorage(ctrl) @@ -201,6 +221,49 @@ func TestSelectGloasBidUsesValidBuilderWhenLocalBuildIsUnavailable(t *testing.T) require.Same(t, bid, selected.bid) } +func TestProduceBlockUsesConfiguredBuilderWhenLocalExecutionIsUnavailable(t *testing.T) { + ctrl := gomock.NewController(t) + _, _, _, _, postState, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), false) + handler.beaconChainCfg.FuluForkEpoch = 0 + handler.beaconChainCfg.GloasForkEpoch = 0 + handler.beaconChainCfg.InitializeForkSchedule() + require.NoError(t, postState.UpgradeToFulu()) + require.NoError(t, postState.UpgradeToGloas()) + postState.GetBuilders().Append(&cltypes.Builder{Pubkey: common.Bytes48{0x42}}) + + baseRoot := common.Hash{0x41} + targetSlot := postState.Slot() + 1 + forkchoiceStore.HeadVal = baseRoot + forkchoiceStore.HeadPayloadStatusVal = cltypes.PayloadStatusEmpty + engine := execution_client.NewMockExecutionEngine(ctrl) + engine.EXPECT().ForkChoiceUpdate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, errors.New("local execution unavailable")) + handler.engine = engine + + parentBid := postState.GetLatestExecutionPayloadBid() + externalBid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(targetSlot, 0, 10)} + externalBid.Message.ParentBlockHash = parentBid.ParentBlockHash + externalBid.Message.ParentBlockRoot = baseRoot + builderClient := builder_mock.NewMockBuilderClient(ctrl) + builderClient.EXPECT().RequestExecutionPayloadBid( + gomock.Any(), "https://builder.example", targetSlot, parentBid.ParentBlockHash, baseRoot, gomock.Any(), gomock.Any(), gomock.Any(), + ).Return(externalBid, nil) + handler.builderClient = builderClient + handler.executionPayloadBidService = acceptingExecutionPayloadBidService{} + options := &gloasBlockProductionOptions{builderConfig: &cltypes.BuilderConfig{Builders: []*cltypes.BuilderEntry{{ + URL: "https://builder.example", + Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{ + Data: []byte("https://builder.example"), Slot: targetSlot, + }}, + BuilderBoostFactor: 100, + }}}} + ctx := context.WithValue(t.Context(), gloasBlockProductionOptionsKey{}, options) + + block, err := handler.produceBlock(ctx, 100, postState.Slot(), baseRoot, postState, targetSlot, common.Bytes96{}, common.Hash{}) + require.NoError(t, err) + require.Same(t, externalBid, block.BeaconBody.SignedExecutionPayloadBid) +} + func TestSelectGloasBidCapsExecutionPaymentAndAvoidsOverflow(t *testing.T) { capped := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 1)} capped.Message.ExecutionPayment = 100 @@ -373,6 +436,119 @@ func TestParseGloasPublishedBlockRejectsNonCanonicalSSZ(t *testing.T) { require.Error(t, err) } +func TestPostEthV2BeaconBlocksForwardsGloasBlockToWinningBuilder(t *testing.T) { + _, _, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + forkchoiceStore.OnTickFn = func(uint64) {} + handler.beaconChainCfg.GloasForkEpoch = 0 + handler.beaconChainCfg.InitializeForkSchedule() + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + block.Block.Slot = 1 + block.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = 1 + body, err := json.Marshal(block) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + builderClient := builder_mock.NewMockBuilderClient(ctrl) + builderURL := "https://builder.example" + blockRoot, err := block.Block.HashSSZ() + require.NoError(t, err) + handler.builderRoutes.Add(blockRoot, &builderRoute{url: builderURL}) + forwardedCh := make(chan struct{}) + builderClient.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, forwarded *cltypes.SignedBeaconBlock) error { + require.Equal(t, block.Block.Slot, forwarded.Block.Slot) + close(forwardedCh) + return errors.New("builder unavailable") + }, + ) + handler.builderClient = builderClient + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Eth-Consensus-Version", clparams.GloasVersion.String()) + req.Header.Set("Eth-Builder-Url", builderURL) + + _, err = handler.PostEthV2BeaconBlocks(httptest.NewRecorder(), req) + require.NoError(t, err) + select { + case <-forwardedCh: + case <-time.After(time.Second): + t.Fatal("signed block was not forwarded to the winning builder") + } +} + +func TestPostEthV2BeaconBlocksDoesNotForwardToUnboundBuilder(t *testing.T) { + for _, tc := range []struct { + name string + headerURL string + routeURL string + wrongRoot bool + }{ + {name: "mismatched URL", headerURL: "https://attacker.example", routeURL: "https://builder.example"}, + {name: "different block root", headerURL: "https://builder.example", routeURL: "https://builder.example", wrongRoot: true}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + forkchoiceStore.OnTickFn = func(uint64) {} + handler.beaconChainCfg.GloasForkEpoch = 0 + handler.beaconChainCfg.InitializeForkSchedule() + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + block.Block.Slot = 1 + block.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = 1 + body, err := json.Marshal(block) + require.NoError(t, err) + blockRoot, err := block.Block.HashSSZ() + require.NoError(t, err) + if tc.wrongRoot { + blockRoot = common.Hash{0xff} + } + route := &builderRoute{url: tc.routeURL} + handler.builderRoutes.Add(blockRoot, route) + handler.builderClient = builder_mock.NewMockBuilderClient(gomock.NewController(t)) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Eth-Consensus-Version", clparams.GloasVersion.String()) + req.Header.Set("Eth-Builder-Url", tc.headerURL) + + _, err = handler.PostEthV2BeaconBlocks(httptest.NewRecorder(), req) + require.NoError(t, err) + require.False(t, route.forwarded.Load()) + }) + } +} + +func TestForwardPublishedBlockToBuilderOnlyOnce(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 0 + handler.beaconChainCfg.InitializeForkSchedule() + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + blockRoot, err := block.Block.HashSSZ() + require.NoError(t, err) + builderURL := "https://builder.example" + handler.builderRoutes.Add(blockRoot, &builderRoute{url: builderURL}) + + ctrl := gomock.NewController(t) + builderClient := builder_mock.NewMockBuilderClient(ctrl) + forwardedCh := make(chan struct{}) + builderClient.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, block).DoAndReturn( + func(context.Context, string, *cltypes.SignedBeaconBlock) error { + close(forwardedCh) + return nil + }, + ) + handler.builderClient = builderClient + + for range 32 { + handler.forwardPublishedBlockToBuilder(builderURL, block) + } + select { + case <-forwardedCh: + case <-time.After(time.Second): + t.Fatal("signed block was not forwarded") + } +} + func TestParseBlockPublishingValidationRejectsUnknownV2Value(t *testing.T) { req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks?broadcast_validation=fast", nil) _, err := (&ApiHandler{}).parseBlockPublishingValidation(req, 2) diff --git a/cl/beacon/handler/handler.go b/cl/beacon/handler/handler.go index 60b1d93fd52..0b28a02aff6 100644 --- a/cl/beacon/handler/handler.go +++ b/cl/beacon/handler/handler.go @@ -159,7 +159,12 @@ type ApiHandler struct { // Populated during block production alongside selfBuildPayloads. // [New in Gloas:EIP7732] selfBuildEnvelopes *lru.Cache[selfBuildEnvelopeKey, *cltypes.ExecutionPayloadEnvelope] - builderRoutes *lru.Cache[common.Hash, string] + builderRoutes *lru.Cache[common.Hash, *builderRoute] +} + +type builderRoute struct { + url string + forwarded atomic.Bool } func NewApiHandler( @@ -229,7 +234,7 @@ func NewApiHandler( if err != nil { panic(err) } - builderRoutes, err := lru.New[common.Hash, string]("builderRoutes", 16) + builderRoutes, err := lru.New[common.Hash, *builderRoute]("builderRoutes", 16) if err != nil { panic(err) } diff --git a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go index a8c57e8f301..f9d75890033 100644 --- a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go +++ b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go @@ -80,6 +80,7 @@ type ForkChoiceStorageMock struct { Envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope VerifiedPayloads map[common.Hash]bool OnExecutionPayloadErr error + OnTickFn func(uint64) ValidateExecutionPayloadEnvelopeErr error GetBeaconCommitteeMock func(slot, committeeIndex uint64) ([]uint64, error) @@ -398,6 +399,10 @@ func (f *ForkChoiceStorageMock) OnPayloadAttestationMessage(ctx context.Context, } func (f *ForkChoiceStorageMock) OnTick(time uint64) { + if f.OnTickFn != nil { + f.OnTickFn(time) + return + } panic("implement me") } From 206e25173a43cbf9739d13913b6481be6f03e031 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 01:42:53 +0800 Subject: [PATCH 10/43] cl/beacon: harden Gloas builder handoff --- cl/beacon/handler/block_production.go | 16 ++- cl/beacon/handler/block_production_test.go | 78 +++++++++++-- cl/beacon/handler/builder_route_store.go | 104 ++++++++++++++++++ cl/beacon/handler/builder_route_store_test.go | 71 ++++++++++++ cl/beacon/handler/handler.go | 13 +-- .../services/execution_payload_bid_service.go | 21 +--- .../execution_payload_bid_service_test.go | 22 ++++ 7 files changed, 287 insertions(+), 38 deletions(-) create mode 100644 cl/beacon/handler/builder_route_store.go create mode 100644 cl/beacon/handler/builder_route_store_test.go diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 4513211e2bc..f17e8431d6c 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -796,15 +796,20 @@ func (a *ApiHandler) GetEthV3ValidatorBlock( consensusValue, ) if options := gloasBlockOptionsFromContext(ctx); options != nil && options.selectedBuilderURL != "" { - w.Header().Set("Eth-Builder-Url", options.selectedBuilderURL) if root, err := block.ToExecution().Block.HashSSZ(); err == nil { - a.builderRoutes.Add(root, &builderRoute{url: options.selectedBuilderURL}) + a.setBuilderRouteHeader(w, root, options.selectedBuilderURL) } } return resp, nil } +func (a *ApiHandler) setBuilderRouteHeader(w http.ResponseWriter, root common.Hash, builderURL string) { + if a.builderRoutes != nil && a.builderRoutes.Add(root, builderURL) { + w.Header().Set("Eth-Builder-Url", builderURL) + } +} + func (a *ApiHandler) produceBlock( ctx context.Context, boostFactor uint64, @@ -1816,12 +1821,13 @@ func (a *ApiHandler) forwardPublishedBlockToBuilder(builderURL string, block *cl if err != nil { return } - route, ok := a.builderRoutes.Get(root) - if !ok || route == nil || route.url != builderURL || !route.forwarded.CompareAndSwap(false, true) { + if !a.builderRoutes.Claim(root, builderURL) { return } go func() { - if err := a.builderClient.SubmitSignedBeaconBlock(context.Background(), builderURL, block); err != nil { + err := a.builderClient.SubmitSignedBeaconBlock(context.Background(), builderURL, block) + a.builderRoutes.Complete(root, builderURL, err == nil) + if err != nil { a.logger.Warn("Failed to forward signed block to builder", "err", err) } }() diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index 6f4ebcb5dc4..5a7405d02bb 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -452,7 +452,7 @@ func TestPostEthV2BeaconBlocksForwardsGloasBlockToWinningBuilder(t *testing.T) { builderURL := "https://builder.example" blockRoot, err := block.Block.HashSSZ() require.NoError(t, err) - handler.builderRoutes.Add(blockRoot, &builderRoute{url: builderURL}) + require.True(t, handler.builderRoutes.Add(blockRoot, builderURL)) forwardedCh := make(chan struct{}) builderClient.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, gomock.Any()).DoAndReturn( func(_ context.Context, _ string, forwarded *cltypes.SignedBeaconBlock) error { @@ -502,8 +502,7 @@ func TestPostEthV2BeaconBlocksDoesNotForwardToUnboundBuilder(t *testing.T) { if tc.wrongRoot { blockRoot = common.Hash{0xff} } - route := &builderRoute{url: tc.routeURL} - handler.builderRoutes.Add(blockRoot, route) + require.True(t, handler.builderRoutes.Add(blockRoot, tc.routeURL)) handler.builderClient = builder_mock.NewMockBuilderClient(gomock.NewController(t)) req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks", bytes.NewReader(body)) @@ -513,7 +512,7 @@ func TestPostEthV2BeaconBlocksDoesNotForwardToUnboundBuilder(t *testing.T) { _, err = handler.PostEthV2BeaconBlocks(httptest.NewRecorder(), req) require.NoError(t, err) - require.False(t, route.forwarded.Load()) + require.True(t, handler.builderRoutes.Claim(blockRoot, tc.routeURL)) }) } } @@ -526,27 +525,92 @@ func TestForwardPublishedBlockToBuilderOnlyOnce(t *testing.T) { blockRoot, err := block.Block.HashSSZ() require.NoError(t, err) builderURL := "https://builder.example" - handler.builderRoutes.Add(blockRoot, &builderRoute{url: builderURL}) + require.True(t, handler.builderRoutes.Add(blockRoot, builderURL)) ctrl := gomock.NewController(t) builderClient := builder_mock.NewMockBuilderClient(ctrl) forwardedCh := make(chan struct{}) + release := make(chan struct{}) builderClient.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, block).DoAndReturn( func(context.Context, string, *cltypes.SignedBeaconBlock) error { close(forwardedCh) + <-release return nil }, ) handler.builderClient = builderClient + handler.forwardPublishedBlockToBuilder(builderURL, block) + select { + case <-forwardedCh: + case <-time.After(time.Second): + t.Fatal("signed block was not forwarded") + } for range 32 { handler.forwardPublishedBlockToBuilder(builderURL, block) } + close(release) +} + +func TestForwardPublishedBlockToBuilderDoesNotForwardPreGloasBlock(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.ElectraVersion) + blockRoot, err := block.Block.HashSSZ() + require.NoError(t, err) + builderURL := "https://builder.example" + require.True(t, handler.builderRoutes.Add(blockRoot, builderURL)) + handler.builderClient = builder_mock.NewMockBuilderClient(gomock.NewController(t)) + + handler.forwardPublishedBlockToBuilder(builderURL, block) + + require.True(t, handler.builderRoutes.Claim(blockRoot, builderURL)) +} + +func TestForwardPublishedBlockToBuilderRetriesAfterFailure(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 0 + handler.beaconChainCfg.InitializeForkSchedule() + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + blockRoot, err := block.Block.HashSSZ() + require.NoError(t, err) + builderURL := "https://builder.example" + require.True(t, handler.builderRoutes.Add(blockRoot, builderURL)) + + ctrl := gomock.NewController(t) + builderClient := builder_mock.NewMockBuilderClient(ctrl) + firstDone := make(chan struct{}) + secondDone := make(chan struct{}) + gomock.InOrder( + builderClient.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, block).DoAndReturn( + func(context.Context, string, *cltypes.SignedBeaconBlock) error { + close(firstDone) + return errors.New("temporary failure") + }, + ), + builderClient.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, block).DoAndReturn( + func(context.Context, string, *cltypes.SignedBeaconBlock) error { + close(secondDone) + return nil + }, + ), + ) + handler.builderClient = builderClient + + handler.forwardPublishedBlockToBuilder(builderURL, block) select { - case <-forwardedCh: + case <-firstDone: case <-time.After(time.Second): - t.Fatal("signed block was not forwarded") + t.Fatal("first submission did not finish") } + require.Eventually(t, func() bool { + handler.forwardPublishedBlockToBuilder(builderURL, block) + select { + case <-secondDone: + return true + default: + return false + } + }, time.Second, time.Millisecond) } func TestParseBlockPublishingValidationRejectsUnknownV2Value(t *testing.T) { diff --git a/cl/beacon/handler/builder_route_store.go b/cl/beacon/handler/builder_route_store.go new file mode 100644 index 00000000000..0f0ea4f0e65 --- /dev/null +++ b/cl/beacon/handler/builder_route_store.go @@ -0,0 +1,104 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// SPDX-License-Identifier: LGPL-3.0-or-later + +package handler + +import ( + "sync" + "time" + + "github.com/erigontech/erigon/common" +) + +const ( + builderRouteCapacity = 16 + builderRouteTTL = 2 * time.Minute +) + +type builderRouteState uint8 + +const ( + builderRouteIdle builderRouteState = iota + builderRouteInFlight + builderRouteDelivered +) + +type builderRouteKey struct { + root common.Hash + url string +} + +type builderRoute struct { + state builderRouteState + expiresAt time.Time +} + +type builderRouteStore struct { + mu sync.Mutex + routes map[builderRouteKey]*builderRoute + capacity int + ttl time.Duration + now func() time.Time +} + +func newBuilderRouteStore(capacity int, ttl time.Duration, now func() time.Time) *builderRouteStore { + return &builderRouteStore{ + routes: make(map[builderRouteKey]*builderRoute, capacity), + capacity: capacity, + ttl: ttl, + now: now, + } +} + +func (s *builderRouteStore) Add(root common.Hash, url string) bool { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + s.pruneExpired(now) + key := builderRouteKey{root: root, url: url} + if route, ok := s.routes[key]; ok { + route.expiresAt = now.Add(s.ttl) + return true + } + if len(s.routes) >= s.capacity { + return false + } + s.routes[key] = &builderRoute{state: builderRouteIdle, expiresAt: now.Add(s.ttl)} + return true +} + +func (s *builderRouteStore) Claim(root common.Hash, url string) bool { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + s.pruneExpired(now) + route, ok := s.routes[builderRouteKey{root: root, url: url}] + if !ok || route.state != builderRouteIdle { + return false + } + route.state = builderRouteInFlight + return true +} + +func (s *builderRouteStore) Complete(root common.Hash, url string, delivered bool) { + s.mu.Lock() + defer s.mu.Unlock() + route, ok := s.routes[builderRouteKey{root: root, url: url}] + if !ok || route.state != builderRouteInFlight { + return + } + if delivered { + route.state = builderRouteDelivered + } else { + route.state = builderRouteIdle + } +} + +func (s *builderRouteStore) pruneExpired(now time.Time) { + for key, route := range s.routes { + if route.state != builderRouteInFlight && !now.Before(route.expiresAt) { + delete(s.routes, key) + } + } +} diff --git a/cl/beacon/handler/builder_route_store_test.go b/cl/beacon/handler/builder_route_store_test.go new file mode 100644 index 00000000000..eba23eadd88 --- /dev/null +++ b/cl/beacon/handler/builder_route_store_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// SPDX-License-Identifier: LGPL-3.0-or-later + +package handler + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" +) + +func TestBuilderRouteStoreReaddingDeliveredRouteDoesNotRearm(t *testing.T) { + now := time.Unix(100, 0) + routes := newBuilderRouteStore(2, time.Minute, func() time.Time { return now }) + root := common.Hash{1} + url := "https://builder.example" + + require.True(t, routes.Add(root, url)) + require.True(t, routes.Claim(root, url)) + routes.Complete(root, url, true) + require.True(t, routes.Add(root, url)) + require.False(t, routes.Claim(root, url)) +} + +func TestBuilderRouteHeaderOmittedWhenRouteCapacityIsFull(t *testing.T) { + handler := &ApiHandler{builderRoutes: newBuilderRouteStore(1, time.Minute, time.Now)} + require.True(t, handler.builderRoutes.Add(common.Hash{1}, "https://one.example")) + recorder := httptest.NewRecorder() + + handler.setBuilderRouteHeader(recorder, common.Hash{2}, "https://two.example") + + require.Empty(t, recorder.Header().Get("Eth-Builder-Url")) +} + +func TestBuilderRouteStoreAllowsAliasesForSameRoot(t *testing.T) { + routes := newBuilderRouteStore(2, time.Minute, time.Now) + root := common.Hash{1} + + require.True(t, routes.Add(root, "https://one.example")) + require.True(t, routes.Add(root, "https://two.example")) + require.True(t, routes.Claim(root, "https://one.example")) + require.True(t, routes.Claim(root, "https://two.example")) +} + +func TestBuilderRouteStoreCapacityPreservesAcceptedRoutes(t *testing.T) { + routes := newBuilderRouteStore(2, time.Minute, time.Now) + + require.True(t, routes.Add(common.Hash{1}, "https://one.example")) + require.True(t, routes.Add(common.Hash{2}, "https://two.example")) + require.False(t, routes.Add(common.Hash{3}, "https://three.example")) + require.True(t, routes.Claim(common.Hash{1}, "https://one.example")) + require.True(t, routes.Claim(common.Hash{2}, "https://two.example")) + require.False(t, routes.Claim(common.Hash{3}, "https://three.example")) +} + +func TestBuilderRouteStoreExpiryFreesCapacity(t *testing.T) { + now := time.Unix(100, 0) + routes := newBuilderRouteStore(1, time.Minute, func() time.Time { return now }) + + require.True(t, routes.Add(common.Hash{1}, "https://one.example")) + require.False(t, routes.Add(common.Hash{2}, "https://two.example")) + now = now.Add(time.Minute) + require.True(t, routes.Add(common.Hash{2}, "https://two.example")) + require.False(t, routes.Claim(common.Hash{1}, "https://one.example")) + require.True(t, routes.Claim(common.Hash{2}, "https://two.example")) +} diff --git a/cl/beacon/handler/handler.go b/cl/beacon/handler/handler.go index 0b28a02aff6..4f4364a008b 100644 --- a/cl/beacon/handler/handler.go +++ b/cl/beacon/handler/handler.go @@ -21,6 +21,7 @@ import ( "net/http" "sync" "sync/atomic" + "time" "github.com/go-chi/chi/v5" @@ -159,12 +160,7 @@ type ApiHandler struct { // Populated during block production alongside selfBuildPayloads. // [New in Gloas:EIP7732] selfBuildEnvelopes *lru.Cache[selfBuildEnvelopeKey, *cltypes.ExecutionPayloadEnvelope] - builderRoutes *lru.Cache[common.Hash, *builderRoute] -} - -type builderRoute struct { - url string - forwarded atomic.Bool + builderRoutes *builderRouteStore } func NewApiHandler( @@ -234,10 +230,7 @@ func NewApiHandler( if err != nil { panic(err) } - builderRoutes, err := lru.New[common.Hash, *builderRoute]("builderRoutes", 16) - if err != nil { - panic(err) - } + builderRoutes := newBuilderRouteStore(builderRouteCapacity, builderRouteTTL, time.Now) return &ApiHandler{ logger: logger, validatorParams: validatorParams, diff --git a/cl/phase1/network/services/execution_payload_bid_service.go b/cl/phase1/network/services/execution_payload_bid_service.go index 2d466adb100..37fbd7b3786 100644 --- a/cl/phase1/network/services/execution_payload_bid_service.go +++ b/cl/phase1/network/services/execution_payload_bid_service.go @@ -305,27 +305,12 @@ func (s *executionPayloadBidService) ValidateBid(_ context.Context, msg *cltypes if bid.Slot <= parentHeader.Slot { return fmt.Errorf("bid slot %d is not greater than parent block slot %d", bid.Slot, parentHeader.Slot) } - maxBlobs := int(s.beaconCfg.GetBlobParameters(state.GetEpochAtSlot(s.beaconCfg, bid.Slot)).MaxBlobsPerBlock) - if bid.BlobKzgCommitments.Len() > maxBlobs { - return fmt.Errorf("bid has too many blob_kzg_commitments: %d > %d", bid.BlobKzgCommitments.Len(), maxBlobs) - } - preferences, ok, err := s.matchingProposerPreferences(msg) - if err != nil { + if err := s.validateBidBlobLimit(bid); err != nil { return err } - if !ok || preferences.Message == nil { - return fmt.Errorf("%w: proposer preferences not available", ErrIgnore) - } - if bid.FeeRecipient != preferences.Message.FeeRecipient { - return fmt.Errorf("%w: bid fee recipient does not match proposer preferences", ErrIgnore) - } if _, ok := s.forkchoiceStore.GetRecentExecutionPayloadStatus(bid.ParentBlockHash); !ok { return fmt.Errorf("%w: parent_block_hash %v not known in fork choice", ErrIgnore, bid.ParentBlockHash) } - parentGasLimit, ok := s.forkchoiceStore.GetExecutionPayloadGasLimit(bid.ParentBlockHash) - if !ok || !IsGasLimitTargetCompatible(parentGasLimit, bid.GasLimit, preferences.Message.TargetGasLimit) { - return fmt.Errorf("%w: bid gas limit is not compatible with proposer preferences", ErrIgnore) - } compatible, err := s.isBidCompatibleWithHead(bid) if err != nil { return err @@ -412,6 +397,10 @@ func (s *executionPayloadBidService) validateBidStateless(bid *cltypes.Execution if bid.ExecutionPayment != 0 { return fmt.Errorf("bid execution_payment must be 0, got %d", bid.ExecutionPayment) } + return s.validateBidBlobLimit(bid) +} + +func (s *executionPayloadBidService) validateBidBlobLimit(bid *cltypes.ExecutionPayloadBid) error { epoch := state.GetEpochAtSlot(s.beaconCfg, bid.Slot) maxBlobsPerBlock := int(s.beaconCfg.GetBlobParameters(epoch).MaxBlobsPerBlock) if bid.BlobKzgCommitments.Len() > maxBlobsPerBlock { diff --git a/cl/phase1/network/services/execution_payload_bid_service_test.go b/cl/phase1/network/services/execution_payload_bid_service_test.go index 9c7128fa74e..669f20e98f0 100644 --- a/cl/phase1/network/services/execution_payload_bid_service_test.go +++ b/cl/phase1/network/services/execution_payload_bid_service_test.go @@ -292,6 +292,28 @@ func TestValidateDirectBidDoesNotApplyGossipHighestFilter(t *testing.T) { require.Error(t, service.ProcessMessage(context.Background(), nil, msg)) } +func TestValidateDirectBidDoesNotRequireGossipProposerPreferences(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl) + msg := newTestSignedExecutionPayloadBid(100, 1, 1) + fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated + ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) + + require.Empty(t, epbsPool.ProposerPreferences.Keys()) + require.NoError(t, service.ValidateBid(context.Background(), msg)) +} + +func TestValidateDirectBidAllowsExecutionPayment(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, ethClockMock, fcMock, _ := setupExecutionPayloadBidService(t, ctrl) + msg := newTestSignedExecutionPayloadBid(100, 1, 1) + msg.Message.ExecutionPayment = 1 + fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated + ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) + + require.NoError(t, service.ValidateBid(context.Background(), msg)) +} + func TestExecutionPayloadBidServiceWrongSlot(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() From b95b886a3877edee16d93cba16bebae1e1dafad8 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 02:06:01 +0800 Subject: [PATCH 11/43] cl/beacon: preserve builder handoff lifecycle --- cl/beacon/handler/block_production.go | 29 ++++++++---- cl/beacon/handler/block_production_test.go | 44 ++++++++++--------- cl/beacon/handler/builder_route_store.go | 10 ++++- cl/beacon/handler/builder_route_store_test.go | 23 +++++++++- .../services/execution_payload_bid_service.go | 7 --- .../execution_payload_bid_service_test.go | 17 +++++++ 6 files changed, 91 insertions(+), 39 deletions(-) diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index f17e8431d6c..ebf3ed31cb4 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -796,18 +796,24 @@ func (a *ApiHandler) GetEthV3ValidatorBlock( consensusValue, ) if options := gloasBlockOptionsFromContext(ctx); options != nil && options.selectedBuilderURL != "" { - if root, err := block.ToExecution().Block.HashSSZ(); err == nil { - a.setBuilderRouteHeader(w, root, options.selectedBuilderURL) + root, err := block.ToExecution().Block.HashSSZ() + if err != nil { + return nil, err + } + if err := a.setBuilderRouteHeader(w, root, options.selectedBuilderURL); err != nil { + return nil, err } } return resp, nil } -func (a *ApiHandler) setBuilderRouteHeader(w http.ResponseWriter, root common.Hash, builderURL string) { - if a.builderRoutes != nil && a.builderRoutes.Add(root, builderURL) { - w.Header().Set("Eth-Builder-Url", builderURL) +func (a *ApiHandler) setBuilderRouteHeader(w http.ResponseWriter, root common.Hash, builderURL string) error { + if a.builderRoutes == nil || !a.builderRoutes.Add(root, builderURL) { + return beaconhttp.NewEndpointError(http.StatusServiceUnavailable, errors.New("builder handoff capacity unavailable")) } + w.Header().Set("Eth-Builder-Url", builderURL) + return nil } func (a *ApiHandler) produceBlock( @@ -1825,11 +1831,16 @@ func (a *ApiHandler) forwardPublishedBlockToBuilder(builderURL string, block *cl return } go func() { - err := a.builderClient.SubmitSignedBeaconBlock(context.Background(), builderURL, block) - a.builderRoutes.Complete(root, builderURL, err == nil) - if err != nil { - a.logger.Warn("Failed to forward signed block to builder", "err", err) + var err error + for range 2 { + err = a.builderClient.SubmitSignedBeaconBlock(context.Background(), builderURL, block) + if err == nil { + a.builderRoutes.Complete(root, builderURL, true) + return + } } + a.builderRoutes.Complete(root, builderURL, false) + a.logger.Warn("Failed to forward signed block to builder", "err", err) }() } diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index 5a7405d02bb..384fbf1e50b 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -241,7 +241,7 @@ func TestProduceBlockUsesConfiguredBuilderWhenLocalExecutionIsUnavailable(t *tes handler.engine = engine parentBid := postState.GetLatestExecutionPayloadBid() - externalBid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(targetSlot, 0, 10)} + externalBid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(targetSlot, 0, 0)} externalBid.Message.ParentBlockHash = parentBid.ParentBlockHash externalBid.Message.ParentBlockRoot = baseRoot builderClient := builder_mock.NewMockBuilderClient(ctrl) @@ -453,14 +453,14 @@ func TestPostEthV2BeaconBlocksForwardsGloasBlockToWinningBuilder(t *testing.T) { blockRoot, err := block.Block.HashSSZ() require.NoError(t, err) require.True(t, handler.builderRoutes.Add(blockRoot, builderURL)) - forwardedCh := make(chan struct{}) + forwardedCh := make(chan struct{}, 2) builderClient.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, gomock.Any()).DoAndReturn( func(_ context.Context, _ string, forwarded *cltypes.SignedBeaconBlock) error { require.Equal(t, block.Block.Slot, forwarded.Block.Slot) - close(forwardedCh) + forwardedCh <- struct{}{} return errors.New("builder unavailable") }, - ) + ).Times(2) handler.builderClient = builderClient req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks", bytes.NewReader(body)) @@ -470,10 +470,12 @@ func TestPostEthV2BeaconBlocksForwardsGloasBlockToWinningBuilder(t *testing.T) { _, err = handler.PostEthV2BeaconBlocks(httptest.NewRecorder(), req) require.NoError(t, err) - select { - case <-forwardedCh: - case <-time.After(time.Second): - t.Fatal("signed block was not forwarded to the winning builder") + for range 2 { + select { + case <-forwardedCh: + case <-time.After(time.Second): + t.Fatal("signed block was not forwarded to the winning builder") + } } } @@ -578,12 +580,14 @@ func TestForwardPublishedBlockToBuilderRetriesAfterFailure(t *testing.T) { ctrl := gomock.NewController(t) builderClient := builder_mock.NewMockBuilderClient(ctrl) - firstDone := make(chan struct{}) + firstStarted := make(chan struct{}) + releaseFirst := make(chan struct{}) secondDone := make(chan struct{}) gomock.InOrder( builderClient.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, block).DoAndReturn( func(context.Context, string, *cltypes.SignedBeaconBlock) error { - close(firstDone) + close(firstStarted) + <-releaseFirst return errors.New("temporary failure") }, ), @@ -598,19 +602,19 @@ func TestForwardPublishedBlockToBuilderRetriesAfterFailure(t *testing.T) { handler.forwardPublishedBlockToBuilder(builderURL, block) select { - case <-firstDone: + case <-firstStarted: case <-time.After(time.Second): - t.Fatal("first submission did not finish") + t.Fatal("first submission did not start") } - require.Eventually(t, func() bool { + for range 32 { handler.forwardPublishedBlockToBuilder(builderURL, block) - select { - case <-secondDone: - return true - default: - return false - } - }, time.Second, time.Millisecond) + } + close(releaseFirst) + select { + case <-secondDone: + case <-time.After(time.Second): + t.Fatal("submission was not retried automatically") + } } func TestParseBlockPublishingValidationRejectsUnknownV2Value(t *testing.T) { diff --git a/cl/beacon/handler/builder_route_store.go b/cl/beacon/handler/builder_route_store.go index 0f0ea4f0e65..b2713891d7b 100644 --- a/cl/beacon/handler/builder_route_store.go +++ b/cl/beacon/handler/builder_route_store.go @@ -62,7 +62,15 @@ func (s *builderRouteStore) Add(root common.Hash, url string) bool { return true } if len(s.routes) >= s.capacity { - return false + for existingKey, route := range s.routes { + if route.state == builderRouteDelivered { + delete(s.routes, existingKey) + break + } + } + if len(s.routes) >= s.capacity { + return false + } } s.routes[key] = &builderRoute{state: builderRouteIdle, expiresAt: now.Add(s.ttl)} return true diff --git a/cl/beacon/handler/builder_route_store_test.go b/cl/beacon/handler/builder_route_store_test.go index eba23eadd88..cab560cdd23 100644 --- a/cl/beacon/handler/builder_route_store_test.go +++ b/cl/beacon/handler/builder_route_store_test.go @@ -5,12 +5,15 @@ package handler import ( + "errors" + "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/require" + "github.com/erigontech/erigon/cl/beacon/beaconhttp" "github.com/erigontech/erigon/common" ) @@ -27,13 +30,17 @@ func TestBuilderRouteStoreReaddingDeliveredRouteDoesNotRearm(t *testing.T) { require.False(t, routes.Claim(root, url)) } -func TestBuilderRouteHeaderOmittedWhenRouteCapacityIsFull(t *testing.T) { +func TestBuilderWinningResponseFailsWhenRouteCapacityIsFull(t *testing.T) { handler := &ApiHandler{builderRoutes: newBuilderRouteStore(1, time.Minute, time.Now)} require.True(t, handler.builderRoutes.Add(common.Hash{1}, "https://one.example")) recorder := httptest.NewRecorder() - handler.setBuilderRouteHeader(recorder, common.Hash{2}, "https://two.example") + err := handler.setBuilderRouteHeader(recorder, common.Hash{2}, "https://two.example") + require.Error(t, err) + var endpointErr *beaconhttp.EndpointError + require.True(t, errors.As(err, &endpointErr)) + require.Equal(t, http.StatusServiceUnavailable, endpointErr.Code) require.Empty(t, recorder.Header().Get("Eth-Builder-Url")) } @@ -69,3 +76,15 @@ func TestBuilderRouteStoreExpiryFreesCapacity(t *testing.T) { require.False(t, routes.Claim(common.Hash{1}, "https://one.example")) require.True(t, routes.Claim(common.Hash{2}, "https://two.example")) } + +func TestBuilderRouteStoreEvictsDeliveredRouteBeforeRejectingPromise(t *testing.T) { + routes := newBuilderRouteStore(1, time.Minute, time.Now) + firstRoot := common.Hash{1} + require.True(t, routes.Add(firstRoot, "https://one.example")) + require.True(t, routes.Claim(firstRoot, "https://one.example")) + routes.Complete(firstRoot, "https://one.example", true) + + require.True(t, routes.Add(common.Hash{2}, "https://two.example")) + require.False(t, routes.Claim(firstRoot, "https://one.example")) + require.True(t, routes.Claim(common.Hash{2}, "https://two.example")) +} diff --git a/cl/phase1/network/services/execution_payload_bid_service.go b/cl/phase1/network/services/execution_payload_bid_service.go index 37fbd7b3786..c6ce422a65d 100644 --- a/cl/phase1/network/services/execution_payload_bid_service.go +++ b/cl/phase1/network/services/execution_payload_bid_service.go @@ -311,13 +311,6 @@ func (s *executionPayloadBidService) ValidateBid(_ context.Context, msg *cltypes if _, ok := s.forkchoiceStore.GetRecentExecutionPayloadStatus(bid.ParentBlockHash); !ok { return fmt.Errorf("%w: parent_block_hash %v not known in fork choice", ErrIgnore, bid.ParentBlockHash) } - compatible, err := s.isBidCompatibleWithHead(bid) - if err != nil { - return err - } - if !compatible { - return fmt.Errorf("%w: bid is not compatible with the current head branch", ErrIgnore) - } validationStateEntry, err := s.bidValidationState(bid.ParentBlockRoot, bid.Slot) if err != nil { return err diff --git a/cl/phase1/network/services/execution_payload_bid_service_test.go b/cl/phase1/network/services/execution_payload_bid_service_test.go index 669f20e98f0..35450fa8ebd 100644 --- a/cl/phase1/network/services/execution_payload_bid_service_test.go +++ b/cl/phase1/network/services/execution_payload_bid_service_test.go @@ -314,6 +314,23 @@ func TestValidateDirectBidAllowsExecutionPayment(t *testing.T) { require.NoError(t, service.ValidateBid(context.Background(), msg)) } +func TestValidateDirectBidUsesFrozenParentWhenHeadFlipsToSibling(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, ethClockMock, fcMock, _ := setupExecutionPayloadBidService(t, ctrl) + msg := newTestSignedExecutionPayloadBid(100, 1, 1) + fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated + siblingRoot := common.Hash{0xfa} + fcMock.HeadVal = siblingRoot + fcMock.Headers[siblingRoot] = &cltypes.BeaconBlockHeader{ParentRoot: common.Hash{0xfb}, Slot: 99} + siblingBlock := cltypes.NewBeaconBlock(service.beaconCfg, clparams.GloasVersion) + siblingBlock.Body.SignedExecutionPayloadBid.Message.ParentBlockHash = common.Hash{0xfc} + siblingBlock.Body.SignedExecutionPayloadBid.Message.BlockHash = common.Hash{0xfd} + fcMock.Blocks[siblingRoot] = &cltypes.SignedBeaconBlock{Block: siblingBlock} + ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) + + require.NoError(t, service.ValidateBid(context.Background(), msg)) +} + func TestExecutionPayloadBidServiceWrongSlot(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() From 48be709e882be24d1b5311a84275d71d7a1d68d3 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 02:20:59 +0800 Subject: [PATCH 12/43] cl/beacon: enforce builder bid preferences --- cl/beacon/handler/block_production.go | 48 +++++++++--- cl/beacon/handler/block_production_test.go | 73 +++++++++++++++++++ cl/beacon/handler/builder_route_store.go | 20 ++++- cl/beacon/handler/builder_route_store_test.go | 46 ++++++++++++ 4 files changed, 173 insertions(+), 14 deletions(-) diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index ebf3ed31cb4..94fec64430c 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -1079,6 +1079,16 @@ func (a *ApiHandler) requestConfiguredBuilderBids( if parentBid == nil || len(entries) == 0 { return nil } + proposerIndex, err := baseState.GetBeaconProposerIndexForSlot(targetSlot) + if err != nil { + return nil + } + expectedFeeRecipient := a.feeRecipientForProposal(proposerIndex, targetSlot) + defaultGasLimit := parentBid.GasLimit + if latestParentBid := baseState.GetLatestExecutionPayloadBid(); latestParentBid != nil { + defaultGasLimit = latestParentBid.GasLimit + } + targetGasLimit := a.proposalTargetGasLimit(baseState, targetSlot, proposerIndex, defaultGasLimit) timeout := time.Second if deadline, ok := ctx.Deadline(); ok { remaining := time.Until(deadline) @@ -1118,6 +1128,10 @@ func (a *ApiHandler) requestConfiguredBuilderBids( bid.Message.ParentBlockHash != parentBid.ParentBlockHash || bid.Message.ParentBlockRoot != parentBid.ParentBlockRoot { continue } + if bid.Message.FeeRecipient != expectedFeeRecipient || bid.Message.GasLimit > targetGasLimit || + bid.Message.ExecutionPayment > result.entry.MaxExecutionPayment { + continue + } if builders == nil || bid.Message.BuilderIndex >= uint64(builders.Len()) { continue } @@ -1144,6 +1158,28 @@ func (a *ApiHandler) requestConfiguredBuilderBids( return candidates } +func (a *ApiHandler) proposalTargetGasLimit( + baseState *state.CachingBeaconState, + targetSlot uint64, + proposerIndex uint64, + defaultGasLimit uint64, +) uint64 { + if a.epbsPool == nil { + return defaultGasLimit + } + proposalEpoch := state.GetEpochAtSlot(a.beaconChainCfg, targetSlot) + dependentRoot, err := state.GetProposerDependentRoot(baseState, proposalEpoch) + if err != nil { + log.Trace("Skipping proposer preferences target gas limit", "slot", targetSlot, "err", err) + return defaultGasLimit + } + pref, ok := a.epbsPool.GetPreference(targetSlot, dependentRoot) + if !ok || pref.Message == nil || pref.Message.ValidatorIndex != proposerIndex { + return defaultGasLimit + } + return pref.Message.TargetGasLimit +} + func (a *ApiHandler) getBuilderPayload( ctx context.Context, baseState *state.CachingBeaconState, @@ -1320,19 +1356,9 @@ func (a *ApiHandler) produceBeaconBody( var targetGasLimit *hexutil.Uint64 if stateVersion.AfterOrEqual(clparams.GloasVersion) { if parentBid := baseState.GetLatestExecutionPayloadBid(); parentBid != nil { - tgl := hexutil.Uint64(parentBid.GasLimit) + tgl := hexutil.Uint64(a.proposalTargetGasLimit(baseState, targetSlot, proposerIndex, parentBid.GasLimit)) targetGasLimit = &tgl } - if a.epbsPool != nil { - proposalEpoch := state.GetEpochAtSlot(a.beaconChainCfg, targetSlot) - dependentRoot, err := state.GetProposerDependentRoot(baseState, proposalEpoch) - if err != nil { - log.Trace("Skipping proposer preferences target gas limit", "slot", targetSlot, "err", err) - } else if pref, ok := a.epbsPool.GetPreference(targetSlot, dependentRoot); ok && pref.Message != nil && pref.Message.ValidatorIndex == proposerIndex { - tgl := hexutil.Uint64(pref.Message.TargetGasLimit) - targetGasLimit = &tgl - } - } } var executionPayload *cltypes.Eth1Block executionValue := new(big.Int) diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index 384fbf1e50b..deb677b57d1 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -50,6 +50,7 @@ import ( gossip_mock "github.com/erigontech/erigon/cl/phase1/network/gossip/mock_services" network_services_mock "github.com/erigontech/erigon/cl/phase1/network/services/mock_services" serviceinterface "github.com/erigontech/erigon/cl/phase1/network/services/service_interface" + "github.com/erigontech/erigon/cl/pool" sync_pool_mock "github.com/erigontech/erigon/cl/validator/sync_contribution_pool/mock_services" "github.com/erigontech/erigon/cl/validator/validator_params" "github.com/erigontech/erigon/common" @@ -244,6 +245,8 @@ func TestProduceBlockUsesConfiguredBuilderWhenLocalExecutionIsUnavailable(t *tes externalBid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(targetSlot, 0, 0)} externalBid.Message.ParentBlockHash = parentBid.ParentBlockHash externalBid.Message.ParentBlockRoot = baseRoot + externalBid.Message.FeeRecipient = common.Address{} + externalBid.Message.GasLimit = parentBid.GasLimit builderClient := builder_mock.NewMockBuilderClient(ctrl) builderClient.EXPECT().RequestExecutionPayloadBid( gomock.Any(), "https://builder.example", targetSlot, parentBid.ParentBlockHash, baseRoot, gomock.Any(), gomock.Any(), gomock.Any(), @@ -264,6 +267,76 @@ func TestProduceBlockUsesConfiguredBuilderWhenLocalExecutionIsUnavailable(t *tes require.Same(t, externalBid, block.BeaconBody.SignedExecutionPayloadBid) } +func TestRequestConfiguredBuilderBidsAppliesLocalProposalPolicy(t *testing.T) { + for _, tc := range []struct { + name string + feeRecipient common.Address + gasLimit uint64 + execPayment uint64 + maxPayment uint64 + preferenceGas uint64 + want int + }{ + {name: "no P2P preference uses local defaults", feeRecipient: common.Address{0x42}, gasLimit: 30_000_000, want: 1}, + {name: "wrong fee recipient", feeRecipient: common.Address{0x43}, gasLimit: 30_000_000}, + {name: "gas limit at target", feeRecipient: common.Address{0x42}, gasLimit: 30_000_000, want: 1}, + {name: "gas limit above target", feeRecipient: common.Address{0x42}, gasLimit: 30_000_001}, + {name: "P2P gas preference at target", feeRecipient: common.Address{0x42}, gasLimit: 25_000_000, preferenceGas: 25_000_000, want: 1}, + {name: "P2P gas preference above target", feeRecipient: common.Address{0x42}, gasLimit: 25_000_001, preferenceGas: 25_000_000}, + {name: "execution payment at cap", feeRecipient: common.Address{0x42}, gasLimit: 30_000_000, execPayment: 5, maxPayment: 5, want: 1}, + {name: "execution payment above cap", feeRecipient: common.Address{0x42}, gasLimit: 30_000_000, execPayment: 6, maxPayment: 5}, + } { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + _, _, _, _, postState, handler, _, _, _, validatorParams := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), false) + handler.beaconChainCfg.FuluForkEpoch = 0 + handler.beaconChainCfg.GloasForkEpoch = 0 + handler.beaconChainCfg.InitializeForkSchedule() + require.NoError(t, postState.UpgradeToFulu()) + require.NoError(t, postState.UpgradeToGloas()) + postState.GetBuilders().Append(&cltypes.Builder{Pubkey: common.Bytes48{0x42}}) + targetSlot := postState.Slot() + 1 + proposerIndex, err := postState.GetBeaconProposerIndexForSlot(targetSlot) + require.NoError(t, err) + validatorParams.SetFeeRecipient(proposerIndex, common.Address{0x42}) + if tc.preferenceGas != 0 { + handler.epbsPool = pool.NewEpbsPool() + proposalEpoch := state.GetEpochAtSlot(handler.beaconChainCfg, targetSlot) + dependentRoot, err := state.GetProposerDependentRoot(postState, proposalEpoch) + require.NoError(t, err) + handler.epbsPool.ProposerPreferences.Add( + pool.ProposerPreferencesKey{Slot: targetSlot, DependentRoot: dependentRoot}, + &cltypes.SignedProposerPreferences{Message: &cltypes.ProposerPreferences{ + ProposalSlot: targetSlot, ValidatorIndex: proposerIndex, FeeRecipient: common.Address{0x42}, + TargetGasLimit: tc.preferenceGas, DependentRoot: dependentRoot, + }}, + ) + } + parentBid := postState.GetLatestExecutionPayloadBid() + parentBid.GasLimit = 30_000_000 + bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(targetSlot, 0, 1)} + bid.Message.ParentBlockHash = parentBid.ParentBlockHash + bid.Message.ParentBlockRoot = parentBid.ParentBlockRoot + bid.Message.FeeRecipient = tc.feeRecipient + bid.Message.GasLimit = tc.gasLimit + bid.Message.ExecutionPayment = tc.execPayment + builderClient := builder_mock.NewMockBuilderClient(ctrl) + builderClient.EXPECT().RequestExecutionPayloadBid( + gomock.Any(), "https://builder.example", targetSlot, parentBid.ParentBlockHash, parentBid.ParentBlockRoot, + gomock.Any(), gomock.Any(), gomock.Any(), + ).Return(bid, nil) + handler.builderClient = builderClient + handler.executionPayloadBidService = acceptingExecutionPayloadBidService{} + + candidates := handler.requestConfiguredBuilderBids(t.Context(), postState, targetSlot, common.Bytes48{}, parentBid, []*cltypes.BuilderEntry{{ + URL: "https://builder.example", BuilderBoostFactor: 100, MaxExecutionPayment: tc.maxPayment, + }}) + + require.Len(t, candidates, tc.want) + }) + } +} + func TestSelectGloasBidCapsExecutionPaymentAndAvoidsOverflow(t *testing.T) { capped := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 1)} capped.Message.ExecutionPayment = 100 diff --git a/cl/beacon/handler/builder_route_store.go b/cl/beacon/handler/builder_route_store.go index b2713891d7b..a0ed92d2ee7 100644 --- a/cl/beacon/handler/builder_route_store.go +++ b/cl/beacon/handler/builder_route_store.go @@ -5,6 +5,7 @@ package handler import ( + "bytes" "sync" "time" @@ -62,12 +63,18 @@ func (s *builderRouteStore) Add(root common.Hash, url string) bool { return true } if len(s.routes) >= s.capacity { + var oldestKey builderRouteKey + var oldestRoute *builderRoute for existingKey, route := range s.routes { - if route.state == builderRouteDelivered { - delete(s.routes, existingKey) - break + if route.state == builderRouteDelivered && (oldestRoute == nil || route.expiresAt.Before(oldestRoute.expiresAt) || + route.expiresAt.Equal(oldestRoute.expiresAt) && builderRouteKeyLess(existingKey, oldestKey)) { + oldestKey = existingKey + oldestRoute = route } } + if oldestRoute != nil { + delete(s.routes, oldestKey) + } if len(s.routes) >= s.capacity { return false } @@ -76,6 +83,13 @@ func (s *builderRouteStore) Add(root common.Hash, url string) bool { return true } +func builderRouteKeyLess(left, right builderRouteKey) bool { + if order := bytes.Compare(left.root[:], right.root[:]); order != 0 { + return order < 0 + } + return left.url < right.url +} + func (s *builderRouteStore) Claim(root common.Hash, url string) bool { s.mu.Lock() defer s.mu.Unlock() diff --git a/cl/beacon/handler/builder_route_store_test.go b/cl/beacon/handler/builder_route_store_test.go index cab560cdd23..c5e799fefbf 100644 --- a/cl/beacon/handler/builder_route_store_test.go +++ b/cl/beacon/handler/builder_route_store_test.go @@ -6,6 +6,7 @@ package handler import ( "errors" + "fmt" "net/http" "net/http/httptest" "testing" @@ -88,3 +89,48 @@ func TestBuilderRouteStoreEvictsDeliveredRouteBeforeRejectingPromise(t *testing. require.False(t, routes.Claim(firstRoot, "https://one.example")) require.True(t, routes.Claim(common.Hash{2}, "https://two.example")) } + +func TestBuilderRouteStoreEvictsOldestDeliveredRoute(t *testing.T) { + now := time.Unix(100, 0) + routes := newBuilderRouteStore(2, time.Minute, func() time.Time { return now }) + first := builderRouteKey{root: common.Hash{1}, url: "https://one.example"} + second := builderRouteKey{root: common.Hash{2}, url: "https://two.example"} + require.True(t, routes.Add(first.root, first.url)) + require.True(t, routes.Claim(first.root, first.url)) + routes.Complete(first.root, first.url, true) + now = now.Add(time.Second) + require.True(t, routes.Add(second.root, second.url)) + require.True(t, routes.Claim(second.root, second.url)) + routes.Complete(second.root, second.url, true) + + require.True(t, routes.Add(common.Hash{3}, "https://three.example")) + _, firstExists := routes.routes[first] + _, secondExists := routes.routes[second] + require.False(t, firstExists) + require.True(t, secondExists) +} + +func TestBuilderRouteStoreEqualExpiryUsesKeyTieBreak(t *testing.T) { + for _, reverse := range []bool{false, true} { + t.Run(fmt.Sprint("reverse=", reverse), func(t *testing.T) { + routes := newBuilderRouteStore(2, time.Minute, func() time.Time { return time.Unix(100, 0) }) + lower := builderRouteKey{root: common.Hash{1}, url: "https://same.example"} + higher := builderRouteKey{root: common.Hash{2}, url: "https://same.example"} + keys := []builderRouteKey{lower, higher} + if reverse { + keys[0], keys[1] = keys[1], keys[0] + } + for _, key := range keys { + require.True(t, routes.Add(key.root, key.url)) + require.True(t, routes.Claim(key.root, key.url)) + routes.Complete(key.root, key.url, true) + } + + require.True(t, routes.Add(common.Hash{3}, "https://three.example")) + _, lowerExists := routes.routes[lower] + _, higherExists := routes.routes[higher] + require.False(t, lowerExists) + require.True(t, higherExists) + }) + } +} From aaa360f63373b2c7cda9d40d97d6d522b3128e73 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 02:26:14 +0800 Subject: [PATCH 13/43] cl/beacon: cap builder payment valuation --- cl/beacon/handler/block_production.go | 3 +-- cl/beacon/handler/block_production_test.go | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 94fec64430c..416882d0a1c 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -1128,8 +1128,7 @@ func (a *ApiHandler) requestConfiguredBuilderBids( bid.Message.ParentBlockHash != parentBid.ParentBlockHash || bid.Message.ParentBlockRoot != parentBid.ParentBlockRoot { continue } - if bid.Message.FeeRecipient != expectedFeeRecipient || bid.Message.GasLimit > targetGasLimit || - bid.Message.ExecutionPayment > result.entry.MaxExecutionPayment { + if bid.Message.FeeRecipient != expectedFeeRecipient || bid.Message.GasLimit > targetGasLimit { continue } if builders == nil || bid.Message.BuilderIndex >= uint64(builders.Len()) { diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index deb677b57d1..11a8ff7279b 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -284,7 +284,7 @@ func TestRequestConfiguredBuilderBidsAppliesLocalProposalPolicy(t *testing.T) { {name: "P2P gas preference at target", feeRecipient: common.Address{0x42}, gasLimit: 25_000_000, preferenceGas: 25_000_000, want: 1}, {name: "P2P gas preference above target", feeRecipient: common.Address{0x42}, gasLimit: 25_000_001, preferenceGas: 25_000_000}, {name: "execution payment at cap", feeRecipient: common.Address{0x42}, gasLimit: 30_000_000, execPayment: 5, maxPayment: 5, want: 1}, - {name: "execution payment above cap", feeRecipient: common.Address{0x42}, gasLimit: 30_000_000, execPayment: 6, maxPayment: 5}, + {name: "execution payment above cap remains a candidate", feeRecipient: common.Address{0x42}, gasLimit: 30_000_000, execPayment: 6, maxPayment: 5, want: 1}, } { t.Run(tc.name, func(t *testing.T) { ctrl := gomock.NewController(t) From 0d0864d561245c65b549b3426dd677e2cc9b3a54 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 02:31:23 +0800 Subject: [PATCH 14/43] cl/beacon: preserve full builder bid value --- cl/beacon/handler/block_production.go | 23 +++++++++--------- cl/beacon/handler/block_production_test.go | 27 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 416882d0a1c..94c1b2d61ea 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -26,7 +26,6 @@ import ( "io" "math" "math/big" - "math/bits" "net/http" "slices" "strconv" @@ -1043,21 +1042,21 @@ func selectGloasBid(localExecutionValueWei *big.Int, candidates []gloasBidCandid continue } countedExecutionPayment := min(candidate.bid.Message.ExecutionPayment, candidate.maxExecutionPayment) - selectionValue, carry := bits.Add64(candidate.bid.Message.Value, countedExecutionPayment, 0) - if carry != 0 { - selectionValue = math.MaxUint64 - } - if selectionValue < candidate.minBid { + selectionValue := new(big.Int).Add( + new(big.Int).SetUint64(candidate.bid.Message.Value), + new(big.Int).SetUint64(countedExecutionPayment), + ) + if selectionValue.Cmp(new(big.Int).SetUint64(candidate.minBid)) < 0 { continue } - selectionValueWei := new(big.Int).Mul(new(big.Int).SetUint64(selectionValue), big.NewInt(1_000_000_000)) + selectionValueWei := new(big.Int).Mul(selectionValue, big.NewInt(1_000_000_000)) weighted := new(big.Int).Mul(selectionValueWei, new(big.Int).SetUint64(candidate.boostFactor)) if best == nil || weighted.Cmp(bestWeighted) > 0 { - actualValue, actualCarry := bits.Add64(candidate.bid.Message.Value, candidate.bid.Message.ExecutionPayment, 0) - if actualCarry != 0 { - actualValue = math.MaxUint64 - } - candidate.executionValueWei = new(big.Int).Mul(new(big.Int).SetUint64(actualValue), big.NewInt(1_000_000_000)) + actualValue := new(big.Int).Add( + new(big.Int).SetUint64(candidate.bid.Message.Value), + new(big.Int).SetUint64(candidate.bid.Message.ExecutionPayment), + ) + candidate.executionValueWei = new(big.Int).Mul(actualValue, big.NewInt(1_000_000_000)) best = candidate bestWeighted.Set(weighted) } diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index 11a8ff7279b..69e304fd313 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -350,7 +350,32 @@ func TestSelectGloasBidCapsExecutionPaymentAndAvoidsOverflow(t *testing.T) { require.NotNil(t, selected) require.Same(t, overflow, selected.bid) - require.Equal(t, new(big.Int).Mul(new(big.Int).SetUint64(^uint64(0)), big.NewInt(1_000_000_000)), selected.executionValueWei) + require.Equal(t, new(big.Int).Mul( + new(big.Int).Add(new(big.Int).SetUint64(math.MaxUint64), new(big.Int).SetUint64(math.MaxUint64)), + big.NewInt(1_000_000_000), + ), selected.executionValueWei) +} + +func TestSelectGloasBidOrdersDistinctTotalsAboveUint64(t *testing.T) { + a := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, math.MaxUint64-1)} + a.Message.ExecutionPayment = 2 + b := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 2, math.MaxUint64)} + b.Message.ExecutionPayment = 2 + expectedValueWei := new(big.Int).Mul( + new(big.Int).Add(new(big.Int).SetUint64(math.MaxUint64), big.NewInt(2)), + big.NewInt(1_000_000_000), + ) + + for _, bids := range [][]*cltypes.SignedExecutionPayloadBid{{a, b}, {b, a}} { + selected := selectGloasBid(nil, []gloasBidCandidate{ + {bid: bids[0], boostFactor: 100, maxExecutionPayment: 2}, + {bid: bids[1], boostFactor: 100, maxExecutionPayment: 2}, + }) + + require.NotNil(t, selected) + require.Same(t, b, selected.bid) + require.Equal(t, expectedValueWei, selected.executionValueWei) + } } func TestSelectGloasBidAppliesMinimumBidToCappedValue(t *testing.T) { From e3ead04d37cc060f89ca4c671319400c3f77fb50 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 02:35:36 +0800 Subject: [PATCH 15/43] cl/beacon: saturate builder bid selection --- cl/beacon/handler/block_production.go | 13 +++++----- cl/beacon/handler/block_production_test.go | 28 +++++++++++++++++----- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 94c1b2d61ea..519d4d87dcc 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -26,6 +26,7 @@ import ( "io" "math" "math/big" + "math/bits" "net/http" "slices" "strconv" @@ -1042,14 +1043,14 @@ func selectGloasBid(localExecutionValueWei *big.Int, candidates []gloasBidCandid continue } countedExecutionPayment := min(candidate.bid.Message.ExecutionPayment, candidate.maxExecutionPayment) - selectionValue := new(big.Int).Add( - new(big.Int).SetUint64(candidate.bid.Message.Value), - new(big.Int).SetUint64(countedExecutionPayment), - ) - if selectionValue.Cmp(new(big.Int).SetUint64(candidate.minBid)) < 0 { + selectionValue, carry := bits.Add64(candidate.bid.Message.Value, countedExecutionPayment, 0) + if carry != 0 { + selectionValue = math.MaxUint64 + } + if selectionValue < candidate.minBid { continue } - selectionValueWei := new(big.Int).Mul(selectionValue, big.NewInt(1_000_000_000)) + selectionValueWei := new(big.Int).Mul(new(big.Int).SetUint64(selectionValue), big.NewInt(1_000_000_000)) weighted := new(big.Int).Mul(selectionValueWei, new(big.Int).SetUint64(candidate.boostFactor)) if best == nil || weighted.Cmp(bestWeighted) > 0 { actualValue := new(big.Int).Add( diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index 69e304fd313..6fe6c50aa5a 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -356,15 +356,11 @@ func TestSelectGloasBidCapsExecutionPaymentAndAvoidsOverflow(t *testing.T) { ), selected.executionValueWei) } -func TestSelectGloasBidOrdersDistinctTotalsAboveUint64(t *testing.T) { +func TestSelectGloasBidSaturatesOverflowTotalsAndKeepsFirstTie(t *testing.T) { a := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, math.MaxUint64-1)} a.Message.ExecutionPayment = 2 b := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 2, math.MaxUint64)} b.Message.ExecutionPayment = 2 - expectedValueWei := new(big.Int).Mul( - new(big.Int).Add(new(big.Int).SetUint64(math.MaxUint64), big.NewInt(2)), - big.NewInt(1_000_000_000), - ) for _, bids := range [][]*cltypes.SignedExecutionPayloadBid{{a, b}, {b, a}} { selected := selectGloasBid(nil, []gloasBidCandidate{ @@ -373,11 +369,31 @@ func TestSelectGloasBidOrdersDistinctTotalsAboveUint64(t *testing.T) { }) require.NotNil(t, selected) - require.Same(t, b, selected.bid) + require.Same(t, bids[0], selected.bid) + expectedValueWei := new(big.Int).Mul( + new(big.Int).Add( + new(big.Int).SetUint64(bids[0].Message.Value), + new(big.Int).SetUint64(bids[0].Message.ExecutionPayment), + ), + big.NewInt(1_000_000_000), + ) require.Equal(t, expectedValueWei, selected.executionValueWei) } } +func TestSelectGloasBidLocalWinsOneWeiAboveSaturatedExternal(t *testing.T) { + bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, math.MaxUint64)} + bid.Message.ExecutionPayment = 1 + localValueWei := new(big.Int).Add( + new(big.Int).Mul(new(big.Int).SetUint64(math.MaxUint64), big.NewInt(1_000_000_000)), + big.NewInt(1), + ) + + require.Nil(t, selectGloasBid(localValueWei, []gloasBidCandidate{{ + bid: bid, boostFactor: 100, maxExecutionPayment: 1, + }})) +} + func TestSelectGloasBidAppliesMinimumBidToCappedValue(t *testing.T) { bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 5)} bid.Message.ExecutionPayment = 10 From 5c70f63a6a284ecb9d46497c05c2579906a74bbc Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 02:48:11 +0800 Subject: [PATCH 16/43] cl: adapt Gloas tests to state setter errors --- cl/beacon/beaconevents/gloas_events_test.go | 12 ++++++------ cl/beacon/handler/state_builders_test.go | 2 +- cl/phase1/network/services/block_service_test.go | 12 ++++++------ .../services/execution_payload_bid_service_test.go | 6 +++--- .../services/execution_payload_service_test.go | 12 ++++++------ .../services/proposer_preferences_service_test.go | 5 ++--- 6 files changed, 24 insertions(+), 25 deletions(-) diff --git a/cl/beacon/beaconevents/gloas_events_test.go b/cl/beacon/beaconevents/gloas_events_test.go index a7d55a373da..d768ea93193 100644 --- a/cl/beacon/beaconevents/gloas_events_test.go +++ b/cl/beacon/beaconevents/gloas_events_test.go @@ -48,9 +48,9 @@ func TestBuildHeadV2DataUsesGenesisRootInEpochZeroAndOne(t *testing.T) { t.Run(test.name, func(t *testing.T) { headState := state.New(&cfg) headState.SetVersion(clparams.GloasVersion) - headState.SetSlot(test.slot) - headState.SetBlockRootAt(0, genesisRoot) - headState.SetBlockRootAt(int(cfg.SlotsPerEpoch-1), epochZeroEndRoot) + require.NoError(t, headState.SetSlot(test.slot)) + require.NoError(t, headState.SetBlockRootAt(0, genesisRoot)) + require.NoError(t, headState.SetBlockRootAt(int(cfg.SlotsPerEpoch-1), epochZeroEndRoot)) event, err := BuildHeadV2Data(&cfg, headState, test.slot, headRoot, stateRoot, "full", true) require.NoError(t, err) @@ -69,11 +69,11 @@ func TestBuildHeadV2DataDoesNotReadPrunedGenesisRoot(t *testing.T) { cfg.SlotsPerHistoricalRoot = 8 headState := state.New(&cfg) headState.SetVersion(clparams.GloasVersion) - headState.SetSlot(10) + require.NoError(t, headState.SetSlot(10)) currentRoot := common.Hash{1} nextRoot := common.Hash{2} - headState.SetBlockRootAt(7, currentRoot) - headState.SetBlockRootAt(1, nextRoot) + require.NoError(t, headState.SetBlockRootAt(7, currentRoot)) + require.NoError(t, headState.SetBlockRootAt(1, nextRoot)) event, err := BuildHeadV2Data(&cfg, headState, 10, common.Hash{3}, common.Hash{4}, "full", false) require.NoError(t, err) diff --git a/cl/beacon/handler/state_builders_test.go b/cl/beacon/handler/state_builders_test.go index f59329934f2..a25fb8f553d 100644 --- a/cl/beacon/handler/state_builders_test.go +++ b/cl/beacon/handler/state_builders_test.go @@ -63,7 +63,7 @@ func setupStateBuildersHandler(t *testing.T) (*ApiHandler, *state.CachingBeaconS registry.Append(builder) } postState.SetBuilders(registry) - syncedData.OnHeadState(postState) + require.NoError(t, syncedData.OnHeadState(postState)) fcu.HeadVal, _ = blocks[len(blocks)-1].Block.HashSSZ() fcu.HeadSlotVal = postState.Slot() fcu.IsRootOptimisticVal = true diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index f7e2d446114..ff7dd15ca6a 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -190,7 +190,7 @@ func TestBlockServiceGossipRejectsBlockOutsideFinalizedChain(t *testing.T) { blocks, _, post := tests.GetBellatrixRandom() blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl) - syncedData.OnHeadState(post) + require.NoError(t, syncedData.OnHeadState(post)) ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes() ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes() fcu.FinalizedCheckpointVal = post.FinalizedCheckpoint() @@ -211,7 +211,7 @@ func TestBlockServiceGossipUsesCheckpointSyncAnchorForFinalizedAncestor(t *testi require.NoError(t, err) require.NoError(t, transition.TransitionState(parentState, blocks[0], nil, false)) blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl) - syncedData.OnHeadState(post) + require.NoError(t, syncedData.OnHeadState(post)) ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes() ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes() fcu.FinalizedCheckpointVal = post.FinalizedCheckpoint() @@ -235,7 +235,7 @@ func TestBlockServiceGossipRejectsUnexpectedProposer(t *testing.T) { mixPosition := (targetEpoch + parentState.BeaconConfig().EpochsPerHistoricalVector - parentState.BeaconConfig().MinSeedLookahead - 1) % parentState.BeaconConfig().EpochsPerHistoricalVector foundUnexpectedProposer := false for nonce := 1; nonce <= 255; nonce++ { - parentState.SetRandaoMixAt(int(mixPosition), common.Hash{byte(nonce)}) + require.NoError(t, parentState.SetRandaoMixAt(int(mixPosition), common.Hash{byte(nonce)})) expected, proposerErr := parentState.GetBeaconProposerIndexForSlot(blocks[1].Block.Slot) require.NoError(t, proposerErr) if expected != blocks[1].Block.ProposerIndex { @@ -246,7 +246,7 @@ func TestBlockServiceGossipRejectsUnexpectedProposer(t *testing.T) { require.True(t, foundUnexpectedProposer) blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl) - syncedData.OnHeadState(post) + require.NoError(t, syncedData.OnHeadState(post)) ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes() ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes() fcu.FinalizedCheckpointVal = post.FinalizedCheckpoint() @@ -478,8 +478,8 @@ func newGloasGossipValidationFixture(t *testing.T, childParentHash func(parentEx validator.SetEffectiveBalance(cfg.MaxEffectiveBalance) parentState := state.New(&cfg) parentState.SetVersion(clparams.GloasVersion) - parentState.SetSlot(parentSlot) - parentState.AddValidator(validator, cfg.MaxEffectiveBalance) + require.NoError(t, parentState.SetSlot(parentSlot)) + require.NoError(t, parentState.AddValidator(validator, cfg.MaxEffectiveBalance)) parentState.SetProposerLookahead(solid.NewUint64VectorSSZ(int((cfg.MinSeedLookahead + 1) * cfg.SlotsPerEpoch))) parentExecutionHead := common.Hash{0x11} parentState.SetLatestBlockHash(parentExecutionHead) diff --git a/cl/phase1/network/services/execution_payload_bid_service_test.go b/cl/phase1/network/services/execution_payload_bid_service_test.go index 35450fa8ebd..be3d0b0c184 100644 --- a/cl/phase1/network/services/execution_payload_bid_service_test.go +++ b/cl/phase1/network/services/execution_payload_bid_service_test.go @@ -128,7 +128,7 @@ func addPreferencesToPoolWithRoot(epbsPool *pool.EpbsPool, slot uint64, dependen func newBidParentState(cfg *clparams.BeaconChainConfig, dependentRoot common.Hash) *state2.CachingBeaconState { s := state2.New(cfg) s.SetVersion(clparams.GloasVersion) - if err := s.SetSlot(100); err != nil { + if err := s.SetSlot(99); err != nil { panic(err) } if err := s.SetBlockRootAt(63, dependentRoot); err != nil { @@ -582,7 +582,7 @@ func TestExecutionPayloadBidServiceUsesGenesisDependentRootInEarlyEpoch(t *testi fcMock.Ancestors[0] = forkchoice.ForkChoiceNode{Root: genesisRoot} fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{} fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot] = newBidParentState(service.beaconCfg, genesisRoot) - fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot].SetSlot(0) + require.NoError(t, fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot].SetSlot(0)) fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot].GetBuilders().Get(1).DepositEpoch = 0 fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated addPreferencesToPoolWithRoot(epbsPool, 1, genesisRoot) @@ -1170,7 +1170,7 @@ func TestExecutionPayloadBidServiceRejectsNonAdvancingKnownParentBeforeQueue(t * service, _, ethClock, fc, _ := setupExecutionPayloadBidService(t, ctrl) msg := newTestSignedExecutionPayloadBid(100, 1, 1000) fc.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{Slot: 100} - fc.StateAtBlockRootVal[msg.Message.ParentBlockRoot].SetSlot(100) + require.NoError(t, fc.StateAtBlockRootVal[msg.Message.ParentBlockRoot].SetSlot(100)) ethClock.EXPECT().GetCurrentSlot().Return(uint64(100)) err := service.ProcessMessage(context.Background(), nil, msg) diff --git a/cl/phase1/network/services/execution_payload_service_test.go b/cl/phase1/network/services/execution_payload_service_test.go index bd2cd52d603..dd749eb4e22 100644 --- a/cl/phase1/network/services/execution_payload_service_test.go +++ b/cl/phase1/network/services/execution_payload_service_test.go @@ -117,9 +117,9 @@ func TestExecutionPayloadServiceEmitsGossipAndImportedEvents(t *testing.T) { forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100, StateRoot: stateRoot}} headState := state.New(cfg) headState.SetVersion(clparams.GloasVersion) - headState.SetSlot(100) - headState.SetBlockRootAt(63, common.Hash{3}) - headState.SetBlockRootAt(95, common.Hash{4}) + require.NoError(t, headState.SetSlot(100)) + require.NoError(t, headState.SetBlockRootAt(63, common.Hash{3})) + require.NoError(t, headState.SetBlockRootAt(95, common.Hash{4})) forkchoiceMock.GetStateAtBlockRootFn = func(root common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) { require.Equal(t, blockRoot, root) require.True(t, alwaysCopy) @@ -177,9 +177,9 @@ func TestExecutionPayloadServiceDoesNotEmitStaleHeadV2AfterReorg(t *testing.T) { forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100, StateRoot: common.Hash{2}}} headState := state.New(cfg) headState.SetVersion(clparams.GloasVersion) - headState.SetSlot(100) - headState.SetBlockRootAt(63, common.Hash{3}) - headState.SetBlockRootAt(95, common.Hash{4}) + require.NoError(t, headState.SetSlot(100)) + require.NoError(t, headState.SetBlockRootAt(63, common.Hash{3})) + require.NoError(t, headState.SetBlockRootAt(95, common.Hash{4})) forkchoiceMock.GetStateAtBlockRootFn = func(root common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) { require.Equal(t, blockRoot, root) require.True(t, alwaysCopy) diff --git a/cl/phase1/network/services/proposer_preferences_service_test.go b/cl/phase1/network/services/proposer_preferences_service_test.go index 98275da59fb..b61386cebea 100644 --- a/cl/phase1/network/services/proposer_preferences_service_test.go +++ b/cl/phase1/network/services/proposer_preferences_service_test.go @@ -535,7 +535,6 @@ func TestProposerPreferencesServiceRequestsIndependentDependentRootState(t *test service, _, ethClockMock, epbsPool, forkChoiceMock := setupProposerPreferencesService(t, ctrl) depState := newProposerPreferencesState(service.beaconCfg, map[uint64]uint64{100: 42}) - require.NoError(t, depState.SetSlot(63)) forkChoiceMock.StateAtBlockRootVal[testDependentRoot] = depState var requestedCopy bool var ownedState *state2.CachingBeaconState @@ -568,11 +567,11 @@ func TestProposerPreferencesValidationStateUsesOwnedStateWithoutSecondCopy(t *te service, _, _, _, _ := setupProposerPreferencesService(t, ctrl) ownedState := state2.New(service.beaconCfg) ownedState.SetVersion(clparams.DenebVersion) - ownedState.AddValidator(solid.NewValidatorFromParameters(common.Bytes48{1}, common.Hash{}, service.beaconCfg.MaxEffectiveBalance, false, 0, 0, service.beaconCfg.FarFutureEpoch, service.beaconCfg.FarFutureEpoch), service.beaconCfg.MaxEffectiveBalance) + require.NoError(t, ownedState.AddValidator(solid.NewValidatorFromParameters(common.Bytes48{1}, common.Hash{}, service.beaconCfg.MaxEffectiveBalance, false, 0, 0, service.beaconCfg.FarFutureEpoch, service.beaconCfg.FarFutureEpoch), service.beaconCfg.MaxEffectiveBalance)) ownedState.SetPreviousEpochParticipationFlags([]cltypes.ParticipationFlags{0}) ownedState.SetCurrentEpochParticipationFlags([]cltypes.ParticipationFlags{0}) ownedState.SetInactivityScores([]uint64{0}) - ownedState.SetSlot(63) + require.NoError(t, ownedState.SetSlot(63)) validationState, err := service.proposerPreferencesValidationState(ownedState, 3) require.NoError(t, err) From 5b937070efb471f06b9b50765734aa33d230f576 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 03:19:18 +0800 Subject: [PATCH 17/43] cl, cmd: gate private builder URLs behind flag --- .../workflows/kurtosis/gloas-caplin-mixed.io | 2 +- .../kurtosis/gloas-three-cl-mixed.io | 2 +- cl/beacon/builder/client.go | 2 +- cl/beacon/builder/client_test.go | 3 +- cl/clparams/config.go | 2 ++ cmd/caplin/caplin1/option.go | 6 +++- cmd/caplin/caplin1/option_test.go | 7 ++++ cmd/caplin/caplincli/config.go | 34 ++++++++++--------- cmd/caplin/caplinflags/flags.go | 1 + cmd/caplin/caplinflags/flags_test.go | 34 +++++++++++++++++++ cmd/caplin/main.go | 1 + cmd/utils/flags.go | 6 ++++ node/cli/default_flags.go | 1 + 13 files changed, 80 insertions(+), 21 deletions(-) create mode 100644 cmd/caplin/caplinflags/flags_test.go diff --git a/.github/workflows/kurtosis/gloas-caplin-mixed.io b/.github/workflows/kurtosis/gloas-caplin-mixed.io index a79c0683955..db6c29863be 100644 --- a/.github/workflows/kurtosis/gloas-caplin-mixed.io +++ b/.github/workflows/kurtosis/gloas-caplin-mixed.io @@ -10,7 +10,7 @@ participants: - cl_type: caplin cl_image: test/erigon:current cl_log_level: "debug" - cl_extra_params: ["--local-discovery", "--caplin.subscribe-all-topics"] + cl_extra_params: ["--local-discovery", "--caplin.subscribe-all-topics", "--caplin.builder.allow-private-urls"] el_type: erigon el_image: test/erigon:current el_log_level: "debug" diff --git a/.github/workflows/kurtosis/gloas-three-cl-mixed.io b/.github/workflows/kurtosis/gloas-three-cl-mixed.io index 2ba647a0a7e..69b8be989fa 100644 --- a/.github/workflows/kurtosis/gloas-three-cl-mixed.io +++ b/.github/workflows/kurtosis/gloas-three-cl-mixed.io @@ -20,7 +20,7 @@ participants: - cl_type: caplin cl_image: test/erigon:current cl_log_level: "debug" - cl_extra_params: ["--local-discovery", "--caplin.subscribe-all-topics"] + cl_extra_params: ["--local-discovery", "--caplin.subscribe-all-topics", "--caplin.builder.allow-private-urls"] el_type: erigon el_image: test/erigon:current el_log_level: "debug" diff --git a/cl/beacon/builder/client.go b/cl/beacon/builder/client.go index a62c52b237c..1ff9b45a3e0 100644 --- a/cl/beacon/builder/client.go +++ b/cl/beacon/builder/client.go @@ -341,7 +341,7 @@ func (b *builderClient) SubmitSignedBeaconBlock(ctx context.Context, builderURL return err } defer b.builderAdmission().Release(1) - target, err := b.builderEndpoint(requestContext, BuilderTargetPolicy{}, builderURL, "eth", "v1", "builder", "beacon_blocks") + target, err := b.builderEndpoint(requestContext, b.targetPolicy, builderURL, "eth", "v1", "builder", "beacon_blocks") if err != nil { return err } diff --git a/cl/beacon/builder/client_test.go b/cl/beacon/builder/client_test.go index 65aad0d19c6..3143ec8ccf4 100644 --- a/cl/beacon/builder/client_test.go +++ b/cl/beacon/builder/client_test.go @@ -670,6 +670,7 @@ func TestPrivateBuilderTargetsRequireExplicitPolicy(t *testing.T) { return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil } require.Error(t, rejected.SubmitBuilderPreferences(t.Context(), "http://builder.local:18550", common.Bytes48{}, request)) + require.Error(t, rejected.SubmitSignedBeaconBlock(t.Context(), "http://builder.local:18550", cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion))) allowed := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{AllowPrivate: true}) allowed.lookupIP = rejected.lookupIP @@ -678,7 +679,7 @@ func TestPrivateBuilderTargetsRequireExplicitPolicy(t *testing.T) { }) allowed.transport = nil require.NoError(t, allowed.SubmitBuilderPreferences(t.Context(), "http://builder.local:18550", common.Bytes48{}, request)) - require.Error(t, allowed.SubmitSignedBeaconBlock(t.Context(), "http://builder.local:18550", cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion))) + require.NoError(t, allowed.SubmitSignedBeaconBlock(t.Context(), "http://builder.local:18550", cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion))) } func TestSubmitSignedBeaconBlock(t *testing.T) { diff --git a/cl/clparams/config.go b/cl/clparams/config.go index 907d42d4221..65674fa0148 100644 --- a/cl/clparams/config.go +++ b/cl/clparams/config.go @@ -67,6 +67,8 @@ type CaplinConfig struct { // CaplinMeVRelayUrl is optional and is used to connect to the external builder service. // If it's set, the node will start in builder mode MevRelayUrl string + // AllowPrivateBuilderURLs permits validator-configured builder URLs to resolve to private addresses. + AllowPrivateBuilderURLs bool // EnableValidatorMonitor is used to enable the validator monitor metrics and corresponding logs EnableValidatorMonitor bool diff --git a/cmd/caplin/caplin1/option.go b/cmd/caplin/caplin1/option.go index 9f2d519e213..7137e47414b 100644 --- a/cmd/caplin/caplin1/option.go +++ b/cmd/caplin/caplin1/option.go @@ -51,5 +51,9 @@ func builderOptionForConfig(config *clparams.CaplinConfig, beaconConfig *clparam if !config.BeaconAPIRouter.Validator && !config.BeaconAPIRouter.Builder { return nil, skippedLegacy } - return WithBuilder(legacyRelayURL, beaconConfig, builder.BuilderTargetPolicy{}), skippedLegacy + return WithBuilder(legacyRelayURL, beaconConfig, builderTargetPolicyForConfig(config)), skippedLegacy +} + +func builderTargetPolicyForConfig(config *clparams.CaplinConfig) builder.BuilderTargetPolicy { + return builder.BuilderTargetPolicy{AllowPrivate: config != nil && config.AllowPrivateBuilderURLs} } diff --git a/cmd/caplin/caplin1/option_test.go b/cmd/caplin/caplin1/option_test.go index ab432c7defb..c83cb0f4d0f 100644 --- a/cmd/caplin/caplin1/option_test.go +++ b/cmd/caplin/caplin1/option_test.go @@ -23,6 +23,13 @@ func TestWithBuilderSupportsDynamicValidatorAPIWithoutRelay(t *testing.T) { require.NotNil(t, got.builderClient) } +func TestBuilderTargetPolicyForConfig(t *testing.T) { + require.False(t, builderTargetPolicyForConfig(nil).AllowPrivate) + require.False(t, builderTargetPolicyForConfig(&clparams.CaplinConfig{}).AllowPrivate) + require.False(t, builderTargetPolicyForConfig(&clparams.CaplinConfig{CustomConfigPath: "config.yaml"}).AllowPrivate) + require.True(t, builderTargetPolicyForConfig(&clparams.CaplinConfig{AllowPrivateBuilderURLs: true}).AllowPrivate) +} + func TestBuilderOptionKeepsDynamicAndLegacyGatingIndependent(t *testing.T) { t.Run("validator API without relay", func(t *testing.T) { config := &clparams.CaplinConfig{} diff --git a/cmd/caplin/caplincli/config.go b/cmd/caplin/caplincli/config.go index 2194876fc55..df72710a5a1 100644 --- a/cmd/caplin/caplincli/config.go +++ b/cmd/caplin/caplincli/config.go @@ -37,22 +37,23 @@ import ( type CaplinCliCfg struct { *sentinelcli.SentinelCliCfg - Chaindata string `json:"chaindata"` - ErigonPrivateApi string `json:"erigon_private_api"` - AllowedEndpoints []string `json:"endpoints"` - BeaconApiReadTimeout time.Duration `json:"beacon_api_read_timeout"` - BeaconApiWriteTimeout time.Duration `json:"beacon_api_write_timeout"` - BeaconAddr string `json:"beacon_addr"` - BeaconProtocol string `json:"beacon_protocol"` - DataDir string `json:"data_dir"` - RunEngineAPI bool `json:"run_engine_api"` - EngineAPIAddr string `json:"engine_api_addr"` - EngineAPIPort int `json:"engine_api_port"` - MevRelayUrl string `json:"mev_relay_url"` - CustomConfig string `json:"custom_config"` - CustomGenesisState string `json:"custom_genesis_state"` - MaxPeerCount uint64 `json:"max_peer_count"` - JwtSecret []byte + Chaindata string `json:"chaindata"` + ErigonPrivateApi string `json:"erigon_private_api"` + AllowedEndpoints []string `json:"endpoints"` + BeaconApiReadTimeout time.Duration `json:"beacon_api_read_timeout"` + BeaconApiWriteTimeout time.Duration `json:"beacon_api_write_timeout"` + BeaconAddr string `json:"beacon_addr"` + BeaconProtocol string `json:"beacon_protocol"` + DataDir string `json:"data_dir"` + RunEngineAPI bool `json:"run_engine_api"` + EngineAPIAddr string `json:"engine_api_addr"` + EngineAPIPort int `json:"engine_api_port"` + MevRelayUrl string `json:"mev_relay_url"` + AllowPrivateBuilderURLs bool `json:"allow_private_builder_urls"` + CustomConfig string `json:"custom_config"` + CustomGenesisState string `json:"custom_genesis_state"` + MaxPeerCount uint64 `json:"max_peer_count"` + JwtSecret []byte AllowedMethods []string `json:"allowed_methods"` AllowedOrigins []string `json:"allowed_origins"` @@ -106,6 +107,7 @@ func SetupCaplinCli(ctx *cli.Command) (cfg *CaplinCliCfg, err error) { cfg.Chaindata = ctx.String(caplinflags.ChaindataFlag.Name) cfg.MevRelayUrl = ctx.String(caplinflags.MevRelayUrl.Name) + cfg.AllowPrivateBuilderURLs = ctx.Bool(utils.CaplinAllowPrivateBuilderURLs.Name) // Custom Chain cfg.CustomConfig = ctx.String(caplinflags.CustomConfig.Name) diff --git a/cmd/caplin/caplinflags/flags.go b/cmd/caplin/caplinflags/flags.go index 261bddc2eb9..26787846c53 100644 --- a/cmd/caplin/caplinflags/flags.go +++ b/cmd/caplin/caplinflags/flags.go @@ -33,6 +33,7 @@ var CliFlags = []cli.Flag{ &EngineApiHostFlag, &EngineApiPortFlag, &MevRelayUrl, + &utils.CaplinAllowPrivateBuilderURLs, &JwtSecret, &CustomConfig, &CustomGenesisState, diff --git a/cmd/caplin/caplinflags/flags_test.go b/cmd/caplin/caplinflags/flags_test.go new file mode 100644 index 00000000000..7e935087ee1 --- /dev/null +++ b/cmd/caplin/caplinflags/flags_test.go @@ -0,0 +1,34 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package caplinflags + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cmd/utils" +) + +func TestPrivateBuilderURLFlagMatchesIntegratedCaplin(t *testing.T) { + for _, flag := range CliFlags { + if flag.Names()[0] == utils.CaplinAllowPrivateBuilderURLs.Name { + return + } + } + require.Fail(t, "private builder URL flag is not registered") +} diff --git a/cmd/caplin/main.go b/cmd/caplin/main.go index 408a28b6a82..375e2b80ace 100644 --- a/cmd/caplin/main.go +++ b/cmd/caplin/main.go @@ -115,6 +115,7 @@ func runCaplinNode(ctx context.Context, cliCtx *cli.Command) error { NetworkId: networkId, LocalDiscovery: cfg.LocalDiscovery, MevRelayUrl: cfg.MevRelayUrl, + AllowPrivateBuilderURLs: cfg.AllowPrivateBuilderURLs, CustomConfigPath: cfg.CustomConfig, CustomGenesisStatePath: cfg.CustomGenesisState, MaxPeerCount: cfg.MaxPeerCount, diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index e3e889630c5..5c595b75865 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -952,6 +952,11 @@ var ( Usage: "MEV relay endpoint. Caplin runs in builder mode if this is set", Value: "", } + CaplinAllowPrivateBuilderURLs = cli.BoolFlag{ + Name: "caplin.builder.allow-private-urls", + Usage: "Allow validator-configured builder URLs to resolve to private or loopback addresses", + Value: false, + } CaplinValidatorMonitorFlag = cli.BoolFlag{ Name: "caplin.validator-monitor", Usage: "Enable caplin validator monitoring metrics", @@ -1847,6 +1852,7 @@ func setCaplin(ctx *cli.Command, cfg *ethconfig.Config) { cfg.CaplinConfig.ColumnKeepSlots = ctx.Uint64(CaplinColumnKeepSlotsFlag.Name) // bunch of extra stuff cfg.CaplinConfig.MevRelayUrl = ctx.String(CaplinMevRelayUrl.Name) + cfg.CaplinConfig.AllowPrivateBuilderURLs = ctx.Bool(CaplinAllowPrivateBuilderURLs.Name) cfg.CaplinConfig.EnableValidatorMonitor = ctx.Bool(CaplinValidatorMonitorFlag.Name) if checkpointUrls := ctx.StringSlice(CaplinCheckpointSyncUrlFlag.Name); len(checkpointUrls) > 0 { clparams.ConfigurableCheckpointsURLs = checkpointUrls diff --git a/node/cli/default_flags.go b/node/cli/default_flags.go index f36f90885b0..c29cb4901b1 100644 --- a/node/cli/default_flags.go +++ b/node/cli/default_flags.go @@ -246,6 +246,7 @@ var DefaultFlags = []cli.Flag{ &utils.CaplinResumeMaxStalenessEpochsFlag, &utils.CaplinEnableSnapshotGeneration, &utils.CaplinMevRelayUrl, + &utils.CaplinAllowPrivateBuilderURLs, &utils.CaplinValidatorMonitorFlag, &utils.CaplinCustomConfigFlag, &utils.CaplinCustomGenesisFlag, From a57bce6e6d9078eade0931f1565bf543f4f62249 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 12:21:13 +0800 Subject: [PATCH 18/43] cl: harden Gloas gossip and beacon APIs --- cl/beacon/beaconevents/gloas_events_test.go | 21 ++ cl/beacon/beaconevents/model.go | 10 + cl/beacon/beaconevents/operation_feed.go | 4 +- cl/beacon/builder/client.go | 51 +++- cl/beacon/builder/client_test.go | 46 ++- cl/beacon/handler/block_production.go | 19 +- cl/beacon/handler/block_production_test.go | 150 ++++++--- cl/beacon/handler/block_production_v4.go | 10 +- cl/beacon/handler/builder_route_store.go | 50 ++- cl/beacon/handler/builder_route_store_test.go | 28 +- cl/beacon/handler/epbs.go | 104 +++++-- cl/beacon/handler/epbs_test.go | 284 +++++++++++++++++- cl/beacon/handler/handler.go | 4 +- cl/cltypes/beacon_block.go | 24 +- cl/cltypes/epbs_builder_api.go | 148 +++++++++ cl/cltypes/epbs_builder_api_test.go | 20 ++ cl/cltypes/epbs_payload.go | 20 ++ cl/cltypes/epbs_proposer.go | 9 + cl/cltypes/indexed_attestation.go | 15 + cl/cltypes/slashings.go | 15 + cl/cltypes/solid/attestation.go | 18 ++ cl/cltypes/solid/attestation_config_test.go | 39 +++ cl/cltypes/strict_attestation_test.go | 43 +++ .../forkchoice/fork_graph/fork_graph_disk.go | 10 +- .../fork_graph/fork_graph_disk_fs.go | 10 + .../forkchoice/fork_graph/fork_graph_test.go | 49 ++- cl/phase1/forkchoice/forkchoice.go | 13 +- cl/phase1/network/services/block_service.go | 72 ++++- .../network/services/block_service_test.go | 60 ++++ .../services/execution_payload_bid_service.go | 14 +- .../execution_payload_bid_service_test.go | 23 +- .../services/execution_payload_service.go | 21 +- .../execution_payload_service_test.go | 44 +++ .../services/payload_attestation_service.go | 2 +- .../payload_attestation_service_test.go | 16 + .../services/proposer_preferences_service.go | 18 +- .../proposer_preferences_service_test.go | 53 ++-- cl/pool/epbs_pool.go | 41 ++- cl/pool/operations_pool_test.go | 21 +- 39 files changed, 1416 insertions(+), 183 deletions(-) create mode 100644 cl/cltypes/strict_attestation_test.go diff --git a/cl/beacon/beaconevents/gloas_events_test.go b/cl/beacon/beaconevents/gloas_events_test.go index d768ea93193..5f41117e6bb 100644 --- a/cl/beacon/beaconevents/gloas_events_test.go +++ b/cl/beacon/beaconevents/gloas_events_test.go @@ -17,6 +17,7 @@ package beaconevents import ( + "encoding/json" "testing" "time" @@ -28,6 +29,26 @@ import ( "github.com/erigontech/erigon/common" ) +func TestVersionedGloasOperationEventsJSONShape(t *testing.T) { + emitter := NewEventEmitter() + ch := make(chan *EventStream, 2) + sub := emitter.Operation().Subscribe(ch) + defer sub.Unsubscribe() + + emitter.Operation().SendPayloadAttestationMessage(&PayloadAttestationMessageData{}) + emitter.Operation().SendExecutionPayloadBid(&SignedExecutionPayloadBidData{}) + + for range 2 { + event := <-ch + encoded, err := json.Marshal(event.Data) + require.NoError(t, err) + var shape map[string]json.RawMessage + require.NoError(t, json.Unmarshal(encoded, &shape)) + require.JSONEq(t, `"gloas"`, string(shape["version"])) + require.Contains(t, shape, "data") + } +} + func TestBuildHeadV2DataUsesGenesisRootInEpochZeroAndOne(t *testing.T) { cfg := clparams.MainnetBeaconConfig genesisRoot := common.Hash{1} diff --git a/cl/beacon/beaconevents/model.go b/cl/beacon/beaconevents/model.go index 76ffe4b6ea1..f9e43b435ea 100644 --- a/cl/beacon/beaconevents/model.go +++ b/cl/beacon/beaconevents/model.go @@ -47,6 +47,16 @@ type ( SignedExecutionPayloadBidData = cltypes.SignedExecutionPayloadBid ) +type VersionedPayloadAttestationMessage struct { + Version string `json:"version"` + Data *PayloadAttestationMessageData `json:"data"` +} + +type VersionedSignedExecutionPayloadBid struct { + Version string `json:"version"` + Data *SignedExecutionPayloadBidData `json:"data"` +} + // DataColumnSidecarData includes block_root and slot for SSE events type DataColumnSidecarData struct { BlockRoot common.Hash `json:"block_root"` diff --git a/cl/beacon/beaconevents/operation_feed.go b/cl/beacon/beaconevents/operation_feed.go index 9ab44de8830..6d92249dde4 100644 --- a/cl/beacon/beaconevents/operation_feed.go +++ b/cl/beacon/beaconevents/operation_feed.go @@ -83,7 +83,7 @@ func (f *operationFeed) SendDataColumnSidecar(value *DataColumnSidecarData) int func (f *operationFeed) SendPayloadAttestationMessage(value *PayloadAttestationMessageData) int { return f.feed.TrySend(&EventStream{ Event: OpPayloadAttestationMessage, - Data: value, + Data: &VersionedPayloadAttestationMessage{Version: "gloas", Data: value}, }) } @@ -91,7 +91,7 @@ func (f *operationFeed) SendPayloadAttestationMessage(value *PayloadAttestationM func (f *operationFeed) SendExecutionPayloadBid(value *SignedExecutionPayloadBidData) int { return f.feed.TrySend(&EventStream{ Event: OpExecutionPayloadBid, - Data: value, + Data: &VersionedSignedExecutionPayloadBid{Version: "gloas", Data: value}, }) } diff --git a/cl/beacon/builder/client.go b/cl/beacon/builder/client.go index 1ff9b45a3e0..8a8eca63d47 100644 --- a/cl/beacon/builder/client.go +++ b/cl/beacon/builder/client.go @@ -51,6 +51,7 @@ const ( builderPreferencesTimeout = time.Second builderBeaconBlockTimeout = time.Second defaultBuilderCallLimit = 32 + defaultPreferenceCallLimit = 24 ) type BuilderTargetPolicy struct { @@ -59,14 +60,16 @@ type BuilderTargetPolicy struct { type builderClient struct { // ref: https://ethereum.github.io/builder-specs/#/ - httpClient *http.Client - url *url.URL - beaconConfig *clparams.BeaconChainConfig - lookupIP func(context.Context, string) ([]net.IPAddr, error) - targetPolicy BuilderTargetPolicy - transport http.RoundTripper - admission *semaphore.Weighted - admissionOnce sync.Once + httpClient *http.Client + url *url.URL + beaconConfig *clparams.BeaconChainConfig + lookupIP func(context.Context, string) ([]net.IPAddr, error) + targetPolicy BuilderTargetPolicy + transport http.RoundTripper + admission *semaphore.Weighted + admissionOnce sync.Once + preferencesAdmission *semaphore.Weighted + preferencesAdmissionOnce sync.Once } func NewBlockBuilderClient(baseUrl string, beaconConfig *clparams.BeaconChainConfig) *builderClient { @@ -91,12 +94,13 @@ func newBlockBuilderClient(baseUrl string, beaconConfig *clparams.BeaconChainCon } } c := &builderClient{ - httpClient: &http.Client{}, - url: u, - beaconConfig: beaconConfig, - targetPolicy: policy, - transport: newPinnedBuilderTransport(nil), - admission: semaphore.NewWeighted(defaultBuilderCallLimit), + httpClient: &http.Client{}, + url: u, + beaconConfig: beaconConfig, + targetPolicy: policy, + transport: newPinnedBuilderTransport(nil), + admission: semaphore.NewWeighted(defaultBuilderCallLimit), + preferencesAdmission: semaphore.NewWeighted(defaultPreferenceCallLimit), } if checkStatus { if err := c.GetStatus(context.Background()); err != nil { @@ -255,10 +259,16 @@ func (b *builderClient) SubmitBuilderPreferences(ctx context.Context, builderURL } requestContext, cancel := context.WithTimeout(ctx, builderPreferencesTimeout) defer cancel() - if err := b.builderAdmission().Acquire(requestContext, 1); err != nil { + preferencesAdmission := b.builderPreferencesAdmission() + if err := preferencesAdmission.Acquire(requestContext, 1); err != nil { return err } - defer b.builderAdmission().Release(1) + defer preferencesAdmission.Release(1) + admission := b.builderAdmission() + if err := admission.Acquire(requestContext, 1); err != nil { + return err + } + defer admission.Release(1) target, err := b.builderEndpoint(requestContext, b.targetPolicy, builderURL, "eth", "v1", "builder", "builder_preferences", proposerPubkey.Hex()) if err != nil { return err @@ -475,6 +485,15 @@ func (b *builderClient) builderAdmission() *semaphore.Weighted { return b.admission } +func (b *builderClient) builderPreferencesAdmission() *semaphore.Weighted { + b.preferencesAdmissionOnce.Do(func() { + if b.preferencesAdmission == nil { + b.preferencesAdmission = semaphore.NewWeighted(defaultPreferenceCallLimit) + } + }) + return b.preferencesAdmission +} + type pinnedBuilderTargetKey struct{} type pinnedBuilderTarget struct { diff --git a/cl/beacon/builder/client_test.go b/cl/beacon/builder/client_test.go index 3143ec8ccf4..2735df4d7f7 100644 --- a/cl/beacon/builder/client_test.go +++ b/cl/beacon/builder/client_test.go @@ -504,10 +504,52 @@ func TestDynamicBuilderCallsShareBoundedAdmission(t *testing.T) { _ = client.SubmitBuilderPreferences(t.Context(), "https://builder.example", common.Bytes48{}, request) }) } - require.Eventually(t, func() bool { return maximum.Load() == defaultBuilderCallLimit }, time.Second, time.Millisecond) + require.Eventually(t, func() bool { return maximum.Load() == defaultPreferenceCallLimit }, time.Second, time.Millisecond) close(release) wg.Wait() - require.EqualValues(t, defaultBuilderCallLimit, maximum.Load()) + require.EqualValues(t, defaultPreferenceCallLimit, maximum.Load()) +} + +func TestBuilderPreferencesAdmissionReservesCriticalCapacity(t *testing.T) { + preferenceStarted := make(chan struct{}, 1) + releasePreference := make(chan struct{}) + client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { + if strings.Contains(r.URL.Path, "/builder_preferences/") { + preferenceStarted <- struct{}{} + select { + case <-releasePreference: + return builderTestResponse(r, http.StatusAccepted, "", nil), nil + case <-r.Context().Done(): + return nil, r.Context().Err() + } + } + return builderTestResponse(r, http.StatusNoContent, "", nil), nil + })) + client.admission = semaphore.NewWeighted(2) + client.preferencesAdmission = semaphore.NewWeighted(1) + request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()} + firstDone := make(chan error, 1) + go func() { + firstDone <- client.SubmitBuilderPreferences(t.Context(), "https://builder.example", common.Bytes48{}, request) + }() + <-preferenceStarted + secondContext, cancelSecond := context.WithCancel(t.Context()) + defer cancelSecond() + secondDone := make(chan error, 1) + go func() { + secondDone <- client.SubmitBuilderPreferences(secondContext, "https://builder.example", common.Bytes48{1}, request) + }() + + criticalContext, cancelCritical := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancelCritical() + bid, err := client.RequestExecutionPayloadBid(criticalContext, "https://builder.example", 12, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), 100*time.Millisecond) + require.NoError(t, err) + require.Nil(t, bid) + + cancelSecond() + require.ErrorIs(t, <-secondDone, context.Canceled) + close(releasePreference) + require.NoError(t, <-firstDone) } func TestDynamicBuilderAdmissionCancellationDoesNotLeakPermit(t *testing.T) { diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 519d4d87dcc..e5b2a254279 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -50,6 +50,7 @@ import ( "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/phase1/execution_client" "github.com/erigontech/erigon/cl/phase1/forkchoice" + clservices "github.com/erigontech/erigon/cl/phase1/network/services" "github.com/erigontech/erigon/cl/phase1/network/subnets" "github.com/erigontech/erigon/cl/pool" ssz2 "github.com/erigontech/erigon/cl/ssz" @@ -1079,6 +1080,10 @@ func (a *ApiHandler) requestConfiguredBuilderBids( if parentBid == nil || len(entries) == 0 { return nil } + parentGasLimit, ok := a.forkchoiceStore.GetExecutionPayloadGasLimit(parentBid.ParentBlockHash) + if !ok { + return nil + } proposerIndex, err := baseState.GetBeaconProposerIndexForSlot(targetSlot) if err != nil { return nil @@ -1128,7 +1133,10 @@ func (a *ApiHandler) requestConfiguredBuilderBids( bid.Message.ParentBlockHash != parentBid.ParentBlockHash || bid.Message.ParentBlockRoot != parentBid.ParentBlockRoot { continue } - if bid.Message.FeeRecipient != expectedFeeRecipient || bid.Message.GasLimit > targetGasLimit { + if bid.Message.FeeRecipient != expectedFeeRecipient { + continue + } + if !clservices.IsGasLimitTargetCompatible(parentGasLimit, bid.Message.GasLimit, targetGasLimit) { continue } if builders == nil || bid.Message.BuilderIndex >= uint64(builders.Len()) { @@ -1852,7 +1860,7 @@ func (a *ApiHandler) forwardPublishedBlockToBuilder(builderURL string, block *cl if err != nil { return } - if !a.builderRoutes.Claim(root, builderURL) { + if !a.builderRoutes.ClaimOrAdd(root, builderURL) { return } go func() { @@ -2130,8 +2138,6 @@ func (a *ApiHandler) parseRequestBeaconBlock( return nil, errors.New("invalid content type") } -// parseGloasRequestBeaconBlock handles GLOAS block publishing where the VC sends -// a SignedBeaconBlock (SSZ) or a JSON object with signed_block + optional envelope. const maxGloasPublishedBlockSize = 10 << 20 func (a *ApiHandler) parseGloasRequestBeaconBlock( @@ -2364,7 +2370,7 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac // If the validator client provided a signed envelope, use it directly (real BLS signature). // Otherwise fall back to constructing one from the cache (legacy/fallback path). if blk.Version() >= clparams.GloasVersion { - if err := a.broadcastSelfBuildEnvelope(ctx, blk); err != nil { + if err := a.applySelfBuildEnvelope(ctx, blk); err != nil { a.logger.Error("Failed to broadcast self-build execution payload envelope", "err", err) } } @@ -2436,8 +2442,7 @@ func collectPublishedPayloadData( return cellsAndProofs, false, nil } -// broadcastSelfBuildEnvelope applies the locally cached self-build payload without gossiping its placeholder signature. -func (a *ApiHandler) broadcastSelfBuildEnvelope(ctx context.Context, blk *cltypes.SignedBeaconBlock) error { +func (a *ApiHandler) applySelfBuildEnvelope(ctx context.Context, blk *cltypes.SignedBeaconBlock) error { bid := blk.Block.Body.GetSignedExecutionPayloadBid() if bid == nil || bid.Message == nil { return nil // no bid in block, nothing to do diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index 6fe6c50aa5a..a955f7de192 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -242,6 +242,7 @@ func TestProduceBlockUsesConfiguredBuilderWhenLocalExecutionIsUnavailable(t *tes handler.engine = engine parentBid := postState.GetLatestExecutionPayloadBid() + forkchoiceStore.ExecutionPayloadGasLimitMap[parentBid.ParentBlockHash] = parentBid.GasLimit externalBid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(targetSlot, 0, 0)} externalBid.Message.ParentBlockHash = parentBid.ParentBlockHash externalBid.Message.ParentBlockRoot = baseRoot @@ -271,24 +272,27 @@ func TestRequestConfiguredBuilderBidsAppliesLocalProposalPolicy(t *testing.T) { for _, tc := range []struct { name string feeRecipient common.Address + parentGas uint64 gasLimit uint64 execPayment uint64 maxPayment uint64 preferenceGas uint64 want int }{ - {name: "no P2P preference uses local defaults", feeRecipient: common.Address{0x42}, gasLimit: 30_000_000, want: 1}, - {name: "wrong fee recipient", feeRecipient: common.Address{0x43}, gasLimit: 30_000_000}, - {name: "gas limit at target", feeRecipient: common.Address{0x42}, gasLimit: 30_000_000, want: 1}, - {name: "gas limit above target", feeRecipient: common.Address{0x42}, gasLimit: 30_000_001}, - {name: "P2P gas preference at target", feeRecipient: common.Address{0x42}, gasLimit: 25_000_000, preferenceGas: 25_000_000, want: 1}, - {name: "P2P gas preference above target", feeRecipient: common.Address{0x42}, gasLimit: 25_000_001, preferenceGas: 25_000_000}, - {name: "execution payment at cap", feeRecipient: common.Address{0x42}, gasLimit: 30_000_000, execPayment: 5, maxPayment: 5, want: 1}, - {name: "execution payment above cap remains a candidate", feeRecipient: common.Address{0x42}, gasLimit: 30_000_000, execPayment: 6, maxPayment: 5, want: 1}, + {name: "no P2P preference clamps toward latest bid target", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 30_029_295, want: 1}, + {name: "wrong fee recipient", feeRecipient: common.Address{0x43}, parentGas: 30_000_000, gasLimit: 30_000_000}, + {name: "high target clamps to parent maximum", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 30_029_295, preferenceGas: 40_000_000, want: 1}, + {name: "high target rejects unclamped target", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 40_000_000, preferenceGas: 40_000_000}, + {name: "low target clamps to parent minimum", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 29_970_705, preferenceGas: 20_000_000, want: 1}, + {name: "low target rejects value below clamp", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 20_000_000, preferenceGas: 20_000_000}, + {name: "zero parent requires zero", feeRecipient: common.Address{0x42}, parentGas: 0, gasLimit: 0, preferenceGas: 1, want: 1}, + {name: "one parent requires one", feeRecipient: common.Address{0x42}, parentGas: 1, gasLimit: 1, preferenceGas: math.MaxUint64, want: 1}, + {name: "execution payment at cap", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 30_000_000, preferenceGas: 30_000_000, execPayment: 5, maxPayment: 5, want: 1}, + {name: "execution payment above cap remains a candidate", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 30_000_000, preferenceGas: 30_000_000, execPayment: 6, maxPayment: 5, want: 1}, } { t.Run(tc.name, func(t *testing.T) { ctrl := gomock.NewController(t) - _, _, _, _, postState, handler, _, _, _, validatorParams := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), false) + _, _, _, _, postState, handler, _, _, forkchoiceStore, validatorParams := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), false) handler.beaconChainCfg.FuluForkEpoch = 0 handler.beaconChainCfg.GloasForkEpoch = 0 handler.beaconChainCfg.InitializeForkSchedule() @@ -312,8 +316,11 @@ func TestRequestConfiguredBuilderBidsAppliesLocalProposalPolicy(t *testing.T) { }}, ) } - parentBid := postState.GetLatestExecutionPayloadBid() - parentBid.GasLimit = 30_000_000 + latestBid := postState.GetLatestExecutionPayloadBid() + latestBid.GasLimit = 99_000_000 + parentBid := latestBid.Clone().(*cltypes.ExecutionPayloadBid) + parentBid.ParentBlockHash = common.Hash{0xa1} + forkchoiceStore.ExecutionPayloadGasLimitMap[parentBid.ParentBlockHash] = tc.parentGas bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(targetSlot, 0, 1)} bid.Message.ParentBlockHash = parentBid.ParentBlockHash bid.Message.ParentBlockRoot = parentBid.ParentBlockRoot @@ -458,6 +465,62 @@ func TestDecodeGloasBlockProductionOptionsRejectsInvalidBuildersPerEntry(t *test require.Equal(t, "https://builder.example", options.builderConfig.Builders[0].URL) } +func TestDecodeGloasBlockProductionOptionsIsolatesDuplicateEntriesJSON(t *testing.T) { + entry := &cltypes.BuilderEntry{ + URL: "https://builder.example", + Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{ + Data: []byte("auth-one"), Slot: 10, + }}, + BuilderPubkeys: []common.Bytes48{{1}}, + } + differentAuth := entry.Clone().(*cltypes.BuilderEntry) + differentAuth.Auth.Message.Data = []byte("auth-two") + first, err := json.Marshal(entry) + require.NoError(t, err) + second, err := json.Marshal(differentAuth) + require.NoError(t, err) + body := fmt.Sprintf(`{"min_bid":"0","builder_boost_factor":"100","builders":[%s,%s,%s]}`, first, first, second) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10?include_payload=true", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Eth-Consensus-Version", "gloas") + + options, err := decodeGloasBlockProductionOptions(httptest.NewRecorder(), req, 10) + require.NoError(t, err) + require.Len(t, options.builderConfig.Builders, 2) + require.Equal(t, []byte("auth-one"), []byte(options.builderConfig.Builders[0].Auth.Message.Data)) + require.Equal(t, []byte("auth-two"), []byte(options.builderConfig.Builders[1].Auth.Message.Data)) +} + +func TestDecodeGloasBlockProductionOptionsIsolatesInvalidAndDuplicateEntriesSSZ(t *testing.T) { + makeEntry := func(url, auth string, slot uint64) *cltypes.BuilderEntry { + return &cltypes.BuilderEntry{ + URL: url, + Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{ + Data: []byte(auth), Slot: slot, + }}, + BuilderPubkeys: []common.Bytes48{{1}}, + } + } + config := &cltypes.BuilderConfig{Builders: []*cltypes.BuilderEntry{ + makeEntry("https://builder.example", "auth-one-unique", 10), + makeEntry("https://builder.example", "auth-two-unique", 10), + makeEntry("https://builder.example", "auth-three-long", 10), + makeEntry("https://wrong-slot.example", "auth-wrong-slot", 11), + }} + body, err := config.EncodeSSZ(nil) + require.NoError(t, err) + body = bytes.Replace(body, []byte("auth-two-unique"), []byte("auth-one-unique"), 1) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10?include_payload=true", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Eth-Consensus-Version", "gloas") + + options, err := decodeGloasBlockProductionOptions(httptest.NewRecorder(), req, 10) + require.NoError(t, err) + require.Len(t, options.builderConfig.Builders, 2) + require.Equal(t, []byte("auth-one-unique"), []byte(options.builderConfig.Builders[0].Auth.Message.Data)) + require.Equal(t, []byte("auth-three-long"), []byte(options.builderConfig.Builders[1].Auth.Message.Data)) +} + func TestDecodeGloasBlockProductionOptionsRejectsInvalidMetadata(t *testing.T) { valid := `{"min_bid":"0","builder_boost_factor":"100","builders":[]}` for _, tc := range []struct { @@ -593,43 +656,38 @@ func TestPostEthV2BeaconBlocksForwardsGloasBlockToWinningBuilder(t *testing.T) { } } -func TestPostEthV2BeaconBlocksDoesNotForwardToUnboundBuilder(t *testing.T) { - for _, tc := range []struct { - name string - headerURL string - routeURL string - wrongRoot bool - }{ - {name: "mismatched URL", headerURL: "https://attacker.example", routeURL: "https://builder.example"}, - {name: "different block root", headerURL: "https://builder.example", routeURL: "https://builder.example", wrongRoot: true}, - } { - t.Run(tc.name, func(t *testing.T) { - _, _, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) - forkchoiceStore.OnTickFn = func(uint64) {} - handler.beaconChainCfg.GloasForkEpoch = 0 - handler.beaconChainCfg.InitializeForkSchedule() - block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) - block.Block.Slot = 1 - block.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = 1 - body, err := json.Marshal(block) - require.NoError(t, err) - blockRoot, err := block.Block.HashSSZ() - require.NoError(t, err) - if tc.wrongRoot { - blockRoot = common.Hash{0xff} - } - require.True(t, handler.builderRoutes.Add(blockRoot, tc.routeURL)) - handler.builderClient = builder_mock.NewMockBuilderClient(gomock.NewController(t)) +func TestPostEthV2BeaconBlocksForwardsUnboundBuilderRoute(t *testing.T) { + _, _, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + forkchoiceStore.OnTickFn = func(uint64) {} + handler.beaconChainCfg.GloasForkEpoch = 0 + handler.beaconChainCfg.InitializeForkSchedule() + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + block.Block.Slot = 1 + block.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = 1 + body, err := json.Marshal(block) + require.NoError(t, err) + builderURL := "https://builder.example" + forwarded := make(chan struct{}) + client := builder_mock.NewMockBuilderClient(gomock.NewController(t)) + client.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, gomock.Any()).DoAndReturn( + func(context.Context, string, *cltypes.SignedBeaconBlock) error { + close(forwarded) + return nil + }, + ) + handler.builderClient = client - req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Eth-Consensus-Version", clparams.GloasVersion.String()) - req.Header.Set("Eth-Builder-Url", tc.headerURL) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Eth-Consensus-Version", clparams.GloasVersion.String()) + req.Header.Set("Eth-Builder-Url", builderURL) - _, err = handler.PostEthV2BeaconBlocks(httptest.NewRecorder(), req) - require.NoError(t, err) - require.True(t, handler.builderRoutes.Claim(blockRoot, tc.routeURL)) - }) + _, err = handler.PostEthV2BeaconBlocks(httptest.NewRecorder(), req) + require.NoError(t, err) + select { + case <-forwarded: + case <-time.After(time.Second): + t.Fatal("signed block was not forwarded by the receiving beacon node") } } diff --git a/cl/beacon/handler/block_production_v4.go b/cl/beacon/handler/block_production_v4.go index 7a0f08994a0..4961bfe647b 100644 --- a/cl/beacon/handler/block_production_v4.go +++ b/cl/beacon/handler/block_production_v4.go @@ -78,17 +78,23 @@ func decodeGloasBlockProductionOptions(w http.ResponseWriter, r *http.Request, t if err != nil { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) } - if err := config.DecodeSSZStrict(body, int(clparams.GloasVersion)); err != nil { + if err := config.DecodeSSZStrictStructural(body, int(clparams.GloasVersion)); err != nil { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) } default: return nil, beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, fmt.Errorf("unsupported content type: %s", contentType)) } validBuilders := make([]*cltypes.BuilderEntry, 0, len(config.Builders)) + seen := make(map[string]struct{}, len(config.Builders)) for _, entry := range config.Builders { - if entry == nil || entry.Auth == nil || entry.Auth.Message == nil || entry.Auth.Message.Slot != targetSlot { + if entry == nil || entry.Validate() != nil || entry.Auth.Message.Slot != targetSlot { continue } + key := entry.URL + "\x00" + string(entry.Auth.Message.Data) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} validBuilders = append(validBuilders, entry) } config.Builders = validBuilders diff --git a/cl/beacon/handler/builder_route_store.go b/cl/beacon/handler/builder_route_store.go index a0ed92d2ee7..086d51f3430 100644 --- a/cl/beacon/handler/builder_route_store.go +++ b/cl/beacon/handler/builder_route_store.go @@ -1,6 +1,18 @@ // Copyright 2026 The Erigon Authors // This file is part of Erigon. -// SPDX-License-Identifier: LGPL-3.0-or-later +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . package handler @@ -103,6 +115,42 @@ func (s *builderRouteStore) Claim(root common.Hash, url string) bool { return true } +func (s *builderRouteStore) ClaimOrAdd(root common.Hash, url string) bool { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + s.pruneExpired(now) + key := builderRouteKey{root: root, url: url} + route, ok := s.routes[key] + if !ok { + if len(s.routes) >= s.capacity { + var oldestKey builderRouteKey + var oldestRoute *builderRoute + for existingKey, candidate := range s.routes { + if candidate.state == builderRouteDelivered && (oldestRoute == nil || candidate.expiresAt.Before(oldestRoute.expiresAt) || + candidate.expiresAt.Equal(oldestRoute.expiresAt) && builderRouteKeyLess(existingKey, oldestKey)) { + oldestKey = existingKey + oldestRoute = candidate + } + } + if oldestRoute != nil { + delete(s.routes, oldestKey) + } + if len(s.routes) >= s.capacity { + return false + } + } + route = &builderRoute{state: builderRouteIdle, expiresAt: now.Add(s.ttl)} + s.routes[key] = route + } + if route.state != builderRouteIdle { + return false + } + route.state = builderRouteInFlight + route.expiresAt = now.Add(s.ttl) + return true +} + func (s *builderRouteStore) Complete(root common.Hash, url string, delivered bool) { s.mu.Lock() defer s.mu.Unlock() diff --git a/cl/beacon/handler/builder_route_store_test.go b/cl/beacon/handler/builder_route_store_test.go index c5e799fefbf..06a729edb7a 100644 --- a/cl/beacon/handler/builder_route_store_test.go +++ b/cl/beacon/handler/builder_route_store_test.go @@ -1,6 +1,18 @@ // Copyright 2026 The Erigon Authors // This file is part of Erigon. -// SPDX-License-Identifier: LGPL-3.0-or-later +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . package handler @@ -55,6 +67,20 @@ func TestBuilderRouteStoreAllowsAliasesForSameRoot(t *testing.T) { require.True(t, routes.Claim(root, "https://two.example")) } +func TestBuilderRouteStoreClaimOrAddIsBoundedAndSingleflight(t *testing.T) { + routes := newBuilderRouteStore(1, time.Minute, time.Now) + root := common.Hash{1} + url := "https://builder.example" + + require.True(t, routes.ClaimOrAdd(root, url)) + require.False(t, routes.ClaimOrAdd(root, url)) + require.False(t, routes.ClaimOrAdd(common.Hash{2}, "https://other.example")) + routes.Complete(root, url, false) + require.True(t, routes.ClaimOrAdd(root, url)) + routes.Complete(root, url, true) + require.False(t, routes.ClaimOrAdd(root, url)) +} + func TestBuilderRouteStoreCapacityPreservesAcceptedRoutes(t *testing.T) { routes := newBuilderRouteStore(2, time.Minute, time.Now) diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index e0546c71417..c60f4ee037b 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -28,7 +28,6 @@ import ( "net/http" "slices" "strconv" - "strings" "github.com/erigontech/erigon/cl/beacon/beaconevents" "github.com/erigontech/erigon/cl/beacon/beaconhttp" @@ -202,6 +201,9 @@ func (a *ApiHandler) GetEthV1ValidatorPayloadAttestationData(w http.ResponseWrit } // Must be GLOAS epoch + if a.beaconChainCfg.SlotsPerEpoch == 0 { + return nil, beaconhttp.NewEndpointError(http.StatusServiceUnavailable, errors.New("slots per epoch is zero")) + } epoch := slot / a.beaconChainCfg.SlotsPerEpoch if epoch < a.beaconChainCfg.GloasForkEpoch { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, @@ -471,6 +473,10 @@ func payloadAttestationPTCPositions(ptc []uint64) map[uint64][]int { // Accepts application/json or application/octet-stream (SSZ). // [New in Gloas:EIP7732] func (a *ApiHandler) PostEthV1BeaconPoolPayloadAttestations(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")).WriteTo(w) + return + } var req []*cltypes.PayloadAttestationMessage contentType, err := requestContentType(r) @@ -500,7 +506,7 @@ func (a *ApiHandler) PostEthV1BeaconPoolPayloadAttestations(w http.ResponseWrite req = make([]*cltypes.PayloadAttestationMessage, 0, count) for i := range count { msg := &cltypes.PayloadAttestationMessage{} - if err := msg.DecodeSSZ(octets[i*msgSize:(i+1)*msgSize], int(clparams.GloasVersion)); err != nil { + if err := msg.DecodeSSZStrict(octets[i*msgSize:(i+1)*msgSize], int(clparams.GloasVersion)); err != nil { beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("failed to decode SSZ PayloadAttestationMessage at index %d: %w", i, err)).WriteTo(w) return @@ -508,10 +514,20 @@ func (a *ApiHandler) PostEthV1BeaconPoolPayloadAttestations(w http.ResponseWrite req = append(req, msg) } case "application/json": - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxEpbsJSONSize)).Decode(&req); err != nil { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxEpbsJSONSize)) + if err := decoder.Decode(&req); err != nil { beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) return } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("request body contains trailing data")).WriteTo(w) + return + } + if uint64(len(req)) > a.beaconChainCfg.PtcSize { + beaconhttp.NewEndpointError(http.StatusBadRequest, + fmt.Errorf("payload attestation count %d exceeds %d", len(req), a.beaconChainCfg.PtcSize)).WriteTo(w) + return + } default: beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, fmt.Errorf("unsupported content type: %s", r.Header.Get("Content-Type"))).WriteTo(w) @@ -616,7 +632,7 @@ func (a *ApiHandler) GetEthV1BeaconPoolProposerPreferences(w http.ResponseWriter // Accepts application/json or application/octet-stream (SSZ). // [New in Gloas:EIP7732] func (a *ApiHandler) PostEthV1BeaconPoolProposerPreferences(w http.ResponseWriter, r *http.Request) { - reqs, ok := decodeProposerPreferencesRequest(w, r) + reqs, ok := decodeProposerPreferencesRequest(w, r, false) if !ok { return } @@ -627,14 +643,18 @@ func (a *ApiHandler) PostEthV1BeaconPoolProposerPreferences(w http.ResponseWrite // POST /eth/v1/validator/proposer_preferences // [New in Gloas:EIP7732] func (a *ApiHandler) PostEthV1ValidatorProposerPreferences(w http.ResponseWriter, r *http.Request) { - reqs, ok := decodeProposerPreferencesRequest(w, r) + if r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")).WriteTo(w) + return + } + reqs, ok := decodeProposerPreferencesRequest(w, r, true) if !ok { return } a.postProposerPreferences(w, r, reqs) } -func decodeProposerPreferencesRequest(w http.ResponseWriter, r *http.Request) ([]*cltypes.SignedProposerPreferences, bool) { +func decodeProposerPreferencesRequest(w http.ResponseWriter, r *http.Request, canonical bool) ([]*cltypes.SignedProposerPreferences, bool) { contentType, err := requestContentType(r) if err != nil { beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, err).WriteTo(w) @@ -674,8 +694,17 @@ func decodeProposerPreferencesRequest(w http.ResponseWriter, r *http.Request) ([ } var reqs []*cltypes.SignedProposerPreferences if err := json.Unmarshal(body, &reqs); err == nil { + if len(reqs) > maxProposerPreferencesRequestItems { + beaconhttp.NewEndpointError(http.StatusBadRequest, + fmt.Errorf("proposer preferences count %d exceeds %d", len(reqs), maxProposerPreferencesRequestItems)).WriteTo(w) + return nil, false + } return reqs, true } + if canonical { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("proposer preferences request must be a JSON array")).WriteTo(w) + return nil, false + } req := &cltypes.SignedProposerPreferences{} if err := json.Unmarshal(body, req); err != nil { beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) @@ -694,17 +723,17 @@ func (a *ApiHandler) postProposerPreferences(w http.ResponseWriter, r *http.Requ beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("empty proposer preferences request")).WriteTo(w) return } + failures := make([]poolingFailure, 0) for i, req := range reqs { if req == nil || req.Message == nil { - beaconhttp.NewEndpointError(http.StatusBadRequest, - fmt.Errorf("missing message in signed proposer preferences at index %d", i)).WriteTo(w) - return + failures = append(failures, poolingFailure{Index: i, Message: "missing message in signed proposer preferences"}) + continue } if a.proposerPreferencesService != nil { if err := a.proposerPreferencesService.ProcessMessage(r.Context(), nil, req); err != nil { - beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) - return + failures = append(failures, poolingFailure{Index: i, Message: err.Error()}) + continue } } @@ -726,7 +755,10 @@ func (a *ApiHandler) postProposerPreferences(w http.ResponseWriter, r *http.Requ } } } - + if len(failures) != 0 { + a.writePoolingFailures(w, failures) + return + } w.WriteHeader(http.StatusOK) } @@ -781,12 +813,19 @@ func (a *ApiHandler) GetEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrite // The envelope is processed through forkchoice and broadcast on gossip. // [New in Gloas:EIP7732] func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWriter, r *http.Request) { + a.postEthV1BeaconExecutionPayloadEnvelope(w, r, true) +} + +func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelopeLegacy(w http.ResponseWriter, r *http.Request) { + a.postEthV1BeaconExecutionPayloadEnvelope(w, r, false) +} + +func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWriter, r *http.Request, canonical bool) { contentType, err := requestContentType(r) if err != nil { beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, err).WriteTo(w) return } - canonical := strings.Contains(r.URL.Path, "/execution_payload_envelopes") blobDataIncluded := false validation := BlockPublishingValidationGossip if canonical { @@ -1083,7 +1122,15 @@ func (a *ApiHandler) storeExecutionPayloadEnvelopeContents(ctx context.Context, // POST /eth/v1/beacon/execution_payload_bid // [New in Gloas:EIP7732] func (a *ApiHandler) PostEthV1BeaconExecutionPayloadBid(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/execution_payload_bids") && r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() { + a.postEthV1BeaconExecutionPayloadBid(w, r, true) +} + +func (a *ApiHandler) postEthV1BeaconExecutionPayloadBidLegacy(w http.ResponseWriter, r *http.Request) { + a.postEthV1BeaconExecutionPayloadBid(w, r, false) +} + +func (a *ApiHandler) postEthV1BeaconExecutionPayloadBid(w http.ResponseWriter, r *http.Request, canonical bool) { + if canonical && r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() { beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")).WriteTo(w) return } @@ -1127,11 +1174,8 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadBid(w http.ResponseWriter, r // Validate via the bid service (checks signature, slot timing, proposer preferences, etc.) if a.executionPayloadBidService != nil { if err := a.executionPayloadBidService.ProcessMessage(r.Context(), nil, req); err != nil { - if !errors.Is(err, clservices.ErrBidQueued) { - beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) - return - } - a.logger.Debug("[Beacon REST] queued execution payload bid", "err", err) + beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) + return } } @@ -1172,12 +1216,34 @@ func (a *ApiHandler) GetEthV1ValidatorExecutionPayloadBid(w http.ResponseWriter, fmt.Errorf("invalid builder_index: %w", err)) } + if a.beaconChainCfg.SlotsPerEpoch == 0 { + return nil, beaconhttp.NewEndpointError(http.StatusServiceUnavailable, errors.New("slots per epoch is zero")) + } // Must be GLOAS epoch epoch := slot / a.beaconChainCfg.SlotsPerEpoch if epoch < a.beaconChainCfg.GloasForkEpoch { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("execution payload bids not available before GLOAS fork")) } + currentSlot := a.ethClock.GetCurrentSlot() + if slot < currentSlot || slot-currentSlot > 1 { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, + fmt.Errorf("execution payload bid slot %d is not current or next", slot)) + } + registered := false + if a.syncedData != nil { + if err := a.syncedData.ViewHeadState(func(headState *state.CachingBeaconState) error { + builders := headState.GetBuilders() + registered = builders != nil && builderIndex < uint64(builders.Len()) && builders.Get(int(builderIndex)) != nil + return nil + }); err != nil { + return nil, beaconhttp.NewEndpointError(http.StatusServiceUnavailable, err) + } + } + if !registered { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, + fmt.Errorf("builder index %d is not registered", builderIndex)) + } if a.epbsPool == nil { return nil, beaconhttp.NewEndpointError(http.StatusServiceUnavailable, diff --git a/cl/beacon/handler/epbs_test.go b/cl/beacon/handler/epbs_test.go index f405747b13b..066c6f15db6 100644 --- a/cl/beacon/handler/epbs_test.go +++ b/cl/beacon/handler/epbs_test.go @@ -17,6 +17,7 @@ package handler import ( + "bytes" "context" "encoding/json" "errors" @@ -60,6 +61,18 @@ func TestPostPayloadAttestationsRejectsNullMessage(t *testing.T) { require.Contains(t, recorder.Body.String(), "missing payload attestation message data") } +func TestPostPayloadAttestationsRequiresGloasVersion(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + for _, version := range []string{"", "fulu"} { + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(`[]`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", version) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + } +} + func TestPostPayloadAttestationsRejectsOversizedSSZ(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) msgSize := (&cltypes.PayloadAttestationMessage{Data: new(cltypes.PayloadAttestationData)}).EncodingSizeSSZ() @@ -67,6 +80,7 @@ func TestPostPayloadAttestationsRejectsOversizedSSZ(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(strings.Repeat("\x00", maxSize+1))) request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request) @@ -85,6 +99,7 @@ func TestPostPayloadAttestationsAcceptsMoreThanBlockAggregateLimitSSZ(t *testing request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(body)) request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request) @@ -102,6 +117,7 @@ func TestPostPayloadAttestationsAcceptsSSZContentTypeParameters(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(string(body))) request.Header.Set("Content-Type", "application/octet-stream; charset=utf-8") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request) @@ -109,6 +125,65 @@ func TestPostPayloadAttestationsAcceptsSSZContentTypeParameters(t *testing.T) { require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) } +func TestPostPayloadAttestationsRejectsNonCanonicalSSZAndTrailingJSON(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + message := &cltypes.PayloadAttestationMessage{Data: new(cltypes.PayloadAttestationData)} + validSSZ, err := message.EncodeSSZ(nil) + require.NoError(t, err) + sszBody := append([]byte(nil), validSSZ...) + sszBody[8+32+8] = 2 + jsonBody, err := json.Marshal([]*cltypes.PayloadAttestationMessage{message}) + require.NoError(t, err) + + for _, tc := range []struct { + name string + contentType string + body []byte + }{ + {name: "invalid boolean", contentType: "application/octet-stream", body: sszBody}, + {name: "trailing SSZ", contentType: "application/octet-stream", body: append(append([]byte(nil), validSSZ...), 0)}, + {name: "trailing JSON", contentType: "application/json", body: append(jsonBody, []byte(`{}`)...)}, + } { + t.Run(tc.name, func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", bytes.NewReader(tc.body)) + request.Header.Set("Content-Type", tc.contentType) + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + }) + } +} + +func TestPostPayloadAttestationsCapsJSONCardinality(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + maxItems := int(handler.beaconChainCfg.PtcSize) + for _, tc := range []struct { + name string + count int + wantIndexed bool + }{ + {name: "maximum", count: maxItems, wantIndexed: true}, + {name: "maximum plus one", count: maxItems + 1}, + } { + t.Run(tc.name, func(t *testing.T) { + body := "[" + strings.TrimSuffix(strings.Repeat("null,", tc.count), ",") + "]" + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + if tc.wantIndexed { + require.Contains(t, recorder.Body.String(), `"failures"`) + } else { + require.NotContains(t, recorder.Body.String(), `"failures"`) + require.Contains(t, recorder.Body.String(), "exceeds") + } + }) + } +} + func TestPostPayloadAttestationsAcceptsQueuedWithoutPooling(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) ctrl := gomock.NewController(t) @@ -124,6 +199,7 @@ func TestPostPayloadAttestationsAcceptsQueuedWithoutPooling(t *testing.T) { require.NoError(t, err) request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(string(body))) request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request) @@ -138,6 +214,7 @@ func TestPostPayloadAttestationsRejectsMalformedContentType(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(`[]`)) request.Header.Set("Content-Type", "application/octet-stream; bad") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request) @@ -150,6 +227,7 @@ func TestPostPayloadAttestationsRejectsUnsupportedContentType(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(`[]`)) request.Header.Set("Content-Type", "text/plain") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request) @@ -157,18 +235,19 @@ func TestPostPayloadAttestationsRejectsUnsupportedContentType(t *testing.T) { require.Equal(t, http.StatusUnsupportedMediaType, recorder.Code, recorder.Body.String()) } -func TestPostExecutionPayloadEnvelopeReturnsForkchoiceError(t *testing.T) { +func TestPostExecutionPayloadEnvelopeAcceptsGossipIntegrationError(t *testing.T) { _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) fcu.OnExecutionPayloadErr = errors.New("invalid execution payload") request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelope", strings.NewReader(`{}`)) request.Header.Set("Content-Type", "application/json; charset=utf-8") + request.Header.Set("Eth-Consensus-Version", "gloas") + request.Header.Set("Eth-Blob-Data-Included", "false") recorder := httptest.NewRecorder() handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) - require.Equal(t, http.StatusInternalServerError, recorder.Code, recorder.Body.String()) - require.Contains(t, recorder.Body.String(), "invalid execution payload") + require.Equal(t, http.StatusAccepted, recorder.Code, recorder.Body.String()) } func TestPostExecutionPayloadEnvelopesRequiresBlobDataHeader(t *testing.T) { @@ -405,6 +484,7 @@ func TestPostExecutionPayloadBidAcceptsSSZ(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", strings.NewReader(string(body))) request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconExecutionPayloadBid(recorder, request) @@ -412,6 +492,59 @@ func TestPostExecutionPayloadBidAcceptsSSZ(t *testing.T) { require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) } +func TestPostExecutionPayloadBidCanonicalPluralRequiresVersionButSingularAliasDoesNot(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(12, 3, 1000)} + body, err := json.Marshal(bid) + require.NoError(t, err) + + canonical := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bids", bytes.NewReader(body)) + canonical.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, canonical) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + + legacy := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", bytes.NewReader(body)) + legacy.Header.Set("Content-Type", "application/json") + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, legacy) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) +} + +func TestPostExecutionPayloadEnvelopesCanonicalPluralRequiresHeadersAndValidation(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + body, err := json.Marshal(envelope) + require.NoError(t, err) + + for _, tc := range []struct { + name string + version string + blobHeader string + query string + message string + }{ + {name: "missing version", message: "Eth-Consensus-Version"}, + {name: "missing blob header", version: "gloas", message: "Eth-Blob-Data-Included"}, + {name: "invalid broadcast validation", version: "gloas", blobHeader: "false", query: "?broadcast_validation=fast", message: "broadcast_validation"}, + } { + t.Run(tc.name, func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes"+tc.query, bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + if tc.version != "" { + request.Header.Set("Eth-Consensus-Version", tc.version) + } + if tc.blobHeader != "" { + request.Header.Set("Eth-Blob-Data-Included", tc.blobHeader) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), tc.message) + }) + } +} + func TestPostExecutionPayloadBidsRejectsTrailingJSON(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(12, 3, 1000)} @@ -428,13 +561,13 @@ func TestPostExecutionPayloadBidsRejectsTrailingJSON(t *testing.T) { require.Contains(t, recorder.Body.String(), "trailing data") } -func TestPostExecutionPayloadBidAcceptsQueuedBid(t *testing.T) { +func TestPostExecutionPayloadBidRejectsIgnoredBid(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) ctrl := gomock.NewController(t) defer ctrl.Finish() bidService := mock_services.NewMockExecutionPayloadBidService(ctrl) - bidService.EXPECT().ProcessMessage(gomock.Any(), gomock.Nil(), gomock.Any()).Return(fmt.Errorf("%w: %w", services.ErrIgnore, services.ErrBidQueued)) + bidService.EXPECT().ProcessMessage(gomock.Any(), gomock.Nil(), gomock.Any()).Return(fmt.Errorf("%w: proposer preferences unavailable", services.ErrIgnore)) handler.executionPayloadBidService = bidService bid := &cltypes.SignedExecutionPayloadBid{ @@ -445,11 +578,12 @@ func TestPostExecutionPayloadBidAcceptsQueuedBid(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", strings.NewReader(string(body))) request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconExecutionPayloadBid(recorder, request) - require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) } func TestPostExecutionPayloadBidRejectsHardIgnore(t *testing.T) { @@ -469,6 +603,7 @@ func TestPostExecutionPayloadBidRejectsHardIgnore(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", strings.NewReader(string(body))) request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconExecutionPayloadBid(recorder, request) @@ -481,6 +616,7 @@ func TestPostExecutionPayloadBidRejectsOversizedSSZ(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", strings.NewReader(strings.Repeat("\x00", int(maxSignedExecutionPayloadBidSSZSize())+1))) request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconExecutionPayloadBid(recorder, request) @@ -493,6 +629,7 @@ func TestPostExecutionPayloadBidRejectsMissingMessage(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", strings.NewReader(`{}`)) request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconExecutionPayloadBid(recorder, request) @@ -506,6 +643,7 @@ func TestPostExecutionPayloadBidRejectsMalformedContentType(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", strings.NewReader(`{}`)) request.Header.Set("Content-Type", "application/octet-stream; bad") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.PostEthV1BeaconExecutionPayloadBid(recorder, request) @@ -514,17 +652,24 @@ func TestPostExecutionPayloadBidRejectsMalformedContentType(t *testing.T) { } func TestGetValidatorExecutionPayloadBidReturnsUnsignedBid(t *testing.T) { - _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + _, _, _, _, postState, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) handler.beaconChainCfg.GloasForkEpoch = 0 + require.NoError(t, postState.UpgradeToFulu()) + require.NoError(t, postState.UpgradeToGloas()) + for range 4 { + postState.GetBuilders().Append(&cltypes.Builder{}) + } + require.NoError(t, handler.syncedData.OnHeadState(postState)) handler.epbsPool = pool.NewEpbsPool() - bid := newTestExecutionPayloadBid(12, 3, 1000) + slot := handler.ethClock.GetCurrentSlot() + bid := newTestExecutionPayloadBid(slot, 3, 1000) handler.epbsPool.HighestBids.Add(pool.HighestBidKey{ Slot: bid.Slot, ParentBlockHash: bid.ParentBlockHash, ParentBlockRoot: bid.ParentBlockRoot, }, &cltypes.SignedExecutionPayloadBid{Message: bid}) - request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/execution_payload_bid/12/3", http.NoBody) + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/eth/v1/validator/execution_payload_bid/%d/3", slot), http.NoBody) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) @@ -533,6 +678,16 @@ func TestGetValidatorExecutionPayloadBidReturnsUnsignedBid(t *testing.T) { require.Contains(t, recorder.Body.String(), `"builder_index":"3"`) require.NotContains(t, recorder.Body.String(), `"signature"`) require.NotContains(t, recorder.Body.String(), `"message"`) + + for _, path := range []string{ + fmt.Sprintf("/eth/v1/validator/execution_payload_bid/%d/3", slot-1), + fmt.Sprintf("/eth/v1/validator/execution_payload_bid/%d/3", slot+2), + fmt.Sprintf("/eth/v1/validator/execution_payload_bid/%d/4", slot), + } { + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, http.NoBody)) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + } } func TestAggregatePayloadAttestationMessagesFiltersAndLimits(t *testing.T) { @@ -708,6 +863,7 @@ func TestPostValidatorProposerPreferencesAcceptsBatchJSON(t *testing.T) { request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/proposer_preferences", strings.NewReader(string(body))) request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) @@ -725,6 +881,116 @@ func TestPostValidatorProposerPreferencesAcceptsBatchJSON(t *testing.T) { require.True(t, ok) } +func TestPostValidatorProposerPreferencesRequiresVersionAndReportsIndexedFailures(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.epbsPool = pool.NewEpbsPool() + preferences := []*cltypes.SignedProposerPreferences{ + {Message: &cltypes.ProposerPreferences{ProposalSlot: 32, DependentRoot: common.Hash{1}}}, + {Message: &cltypes.ProposerPreferences{ProposalSlot: 33, DependentRoot: common.Hash{2}}}, + } + body, err := json.Marshal(preferences) + require.NoError(t, err) + + missingVersion := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/proposer_preferences", bytes.NewReader(body)) + missingVersion.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, missingVersion) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + + ctrl := gomock.NewController(t) + service := mock_services.NewMockProposerPreferencesService(ctrl) + service.EXPECT().ProcessMessage(gomock.Any(), gomock.Nil(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ *uint64, preference *cltypes.SignedProposerPreferences) error { + if preference.Message.ProposalSlot == 32 { + return errors.New("invalid first preference") + } + return nil + }, + ).Times(2) + handler.proposerPreferencesService = service + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/proposer_preferences", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + var response poolingError + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, []poolingFailure{{Index: 0, Message: "invalid first preference"}}, response.Failures) + _, found := handler.epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: 33, DependentRoot: common.Hash{2}}) + require.True(t, found) +} + +func TestPostValidatorProposerPreferencesAcceptsBatchSSZ(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.epbsPool = pool.NewEpbsPool() + preferences := []*cltypes.SignedProposerPreferences{ + {Message: &cltypes.ProposerPreferences{ProposalSlot: 32, DependentRoot: common.Hash{1}}}, + {Message: &cltypes.ProposerPreferences{ProposalSlot: 33, DependentRoot: common.Hash{2}}}, + } + body := make([]byte, 0) + for _, preference := range preferences { + var err error + body, err = preference.EncodeSSZ(body) + require.NoError(t, err) + } + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/proposer_preferences", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) +} + +func TestPostValidatorProposerPreferencesRequiresJSONArrayButPoolRetainsSingletonCompatibility(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.epbsPool = pool.NewEpbsPool() + preference := &cltypes.SignedProposerPreferences{Message: &cltypes.ProposerPreferences{ProposalSlot: 32, DependentRoot: common.Hash{1}}} + body, err := json.Marshal(preference) + require.NoError(t, err) + + validatorRequest := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/proposer_preferences", bytes.NewReader(body)) + validatorRequest.Header.Set("Content-Type", "application/json") + validatorRequest.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, validatorRequest) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + + poolRequest := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/proposer_preferences", bytes.NewReader(body)) + poolRequest.Header.Set("Content-Type", "application/json") + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, poolRequest) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) +} + +func TestPostValidatorProposerPreferencesCapsJSONCardinality(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + for _, tc := range []struct { + name string + count int + wantIndexed bool + }{ + {name: "maximum", count: maxProposerPreferencesRequestItems, wantIndexed: true}, + {name: "maximum plus one", count: maxProposerPreferencesRequestItems + 1}, + } { + t.Run(tc.name, func(t *testing.T) { + body := "[" + strings.TrimSuffix(strings.Repeat("null,", tc.count), ",") + "]" + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/proposer_preferences", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + if tc.wantIndexed { + require.Contains(t, recorder.Body.String(), `"failures"`) + } else { + require.NotContains(t, recorder.Body.String(), `"failures"`) + require.Contains(t, recorder.Body.String(), "exceeds") + } + }) + } +} + func TestPostBeaconPoolProposerPreferencesAcceptsBatchJSON(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) handler.epbsPool = pool.NewEpbsPool() diff --git a/cl/beacon/handler/handler.go b/cl/beacon/handler/handler.go index 4f4364a008b..0eac1665d5a 100644 --- a/cl/beacon/handler/handler.go +++ b/cl/beacon/handler/handler.go @@ -385,9 +385,9 @@ func (a *ApiHandler) init() { // [New in Gloas:EIP7732] r.Get("/execution_payload_envelope/{block_id}", beaconhttp.HandleEndpointFunc(a.GetEthV1BeaconExecutionPayloadEnvelope)) r.Get("/execution_payload_envelopes/{block_id}", beaconhttp.HandleEndpointFunc(a.GetEthV1BeaconExecutionPayloadEnvelope)) - r.Post("/execution_payload_envelope", a.PostEthV1BeaconExecutionPayloadEnvelope) + r.Post("/execution_payload_envelope", a.postEthV1BeaconExecutionPayloadEnvelopeLegacy) r.Post("/execution_payload_envelopes", a.PostEthV1BeaconExecutionPayloadEnvelope) - r.Post("/execution_payload_bid", a.PostEthV1BeaconExecutionPayloadBid) + r.Post("/execution_payload_bid", a.postEthV1BeaconExecutionPayloadBidLegacy) r.Post("/execution_payload_bids", a.PostEthV1BeaconExecutionPayloadBid) r.Route("/states", func(r chi.Router) { r.Route("/{state_id}", func(r chi.Router) { diff --git a/cl/cltypes/beacon_block.go b/cl/cltypes/beacon_block.go index 9854c1b2baa..5a02ca2dd9f 100644 --- a/cl/cltypes/beacon_block.go +++ b/cl/cltypes/beacon_block.go @@ -127,6 +127,10 @@ func (b *SignedBeaconBlock) DecodeSSZ(buf []byte, s int) error { return ssz2.UnmarshalSSZ(buf, s, b.Block, b.Signature[:]) } +func (b *SignedBeaconBlock) DecodeSSZStrict(buf []byte, version int) error { + return ssz2.UnmarshalSSZStrict(buf, version, b.Block, b.Signature[:]) +} + func (b *SignedBeaconBlock) HashSSZ() ([32]byte, error) { return merkle_tree.HashTreeRoot(b.Block, b.Signature[:]) } @@ -201,6 +205,10 @@ func (b *BeaconBlock) DecodeSSZ(buf []byte, version int) error { return ssz2.UnmarshalSSZ(buf, version, &b.Slot, &b.ProposerIndex, b.ParentRoot[:], b.StateRoot[:], b.Body) } +func (b *BeaconBlock) DecodeSSZStrict(buf []byte, version int) error { + return ssz2.UnmarshalSSZStrict(buf, version, &b.Slot, &b.ProposerIndex, b.ParentRoot[:], b.StateRoot[:], b.Body) +} + func (b *BeaconBlock) HashSSZ() ([32]byte, error) { return merkle_tree.HashTreeRoot(b.Slot, b.ProposerIndex, b.ParentRoot[:], b.StateRoot[:], b.Body) } @@ -516,6 +524,14 @@ func (b *BeaconBody) EncodingSizeSSZ() (size int) { } func (b *BeaconBody) DecodeSSZ(buf []byte, version int) error { + return b.decodeSSZ(buf, version, false) +} + +func (b *BeaconBody) DecodeSSZStrict(buf []byte, version int) error { + return b.decodeSSZ(buf, version, true) +} + +func (b *BeaconBody) decodeSSZ(buf []byte, version int, strict bool) error { b.Version = clparams.StateVersion(version) if b.Version >= clparams.GloasVersion { b.resetGloasProgressiveLists() @@ -538,7 +554,13 @@ func (b *BeaconBody) DecodeSSZ(buf []byte, version int) error { } b.ParentExecutionRequests = NewExecutionRequestsWithVersion(b.beaconCfg, b.Version) } - if err := ssz2.UnmarshalSSZ(buf, version, b.getSchema(false)...); err != nil { + var err error + if strict { + err = ssz2.UnmarshalSSZStrict(buf, version, b.getSchema(false)...) + } else { + err = ssz2.UnmarshalSSZ(buf, version, b.getSchema(false)...) + } + if err != nil { return err } diff --git a/cl/cltypes/epbs_builder_api.go b/cl/cltypes/epbs_builder_api.go index 7175c344c9b..e93b5016b55 100644 --- a/cl/cltypes/epbs_builder_api.go +++ b/cl/cltypes/epbs_builder_api.go @@ -299,6 +299,10 @@ func (b *BuilderEntry) validate() error { return validateBuilderAuthData(b.Auth.Message.Data) } +func (b *BuilderEntry) Validate() error { + return b.validate() +} + type BuilderConfig struct { MinBid uint64 `json:"min_bid,string"` BuilderBoostFactor uint64 `json:"builder_boost_factor,string"` @@ -330,6 +334,19 @@ func (b *BuilderConfig) DecodeSSZStrict(buf []byte, version int) error { return b.validate() } +func (b *BuilderConfig) DecodeSSZStrictStructural(buf []byte, version int) error { + b.Builders = nil + rawEntries := make([]*rawBuilderEntry, 0) + if err := ssz2.UnmarshalSSZStrict(buf, version, &b.MinBid, &b.BuilderBoostFactor, &rawBuilderEntryList{values: &rawEntries}); err != nil { + return err + } + b.Builders = make([]*BuilderEntry, len(rawEntries)) + for i, raw := range rawEntries { + b.Builders[i] = raw.entry + } + return nil +} + func (b *BuilderConfig) Clone() clonable.Clonable { clone := &BuilderConfig{MinBid: b.MinBid, BuilderBoostFactor: b.BuilderBoostFactor, Builders: make([]*BuilderEntry, len(b.Builders))} for i, entry := range b.Builders { @@ -743,6 +760,137 @@ func (l *builderEntryList) EncodingSizeSSZ() int { } return size } + +type rawBuilderRequestAuth struct { + data hexutil.Bytes + slot uint64 +} + +func (r *rawBuilderRequestAuth) Static() bool { return false } +func (r *rawBuilderRequestAuth) EncodingSizeSSZ() int { + return 4 + 8 + len(r.data) +} +func (r *rawBuilderRequestAuth) EncodeSSZ(dst []byte) ([]byte, error) { + return ssz2.MarshalSSZ(dst, &rawByteList{value: &r.data, limit: MaxBuilderAuthDataSize}, r.slot) +} +func (r *rawBuilderRequestAuth) DecodeSSZ(buf []byte, version int) error { + return r.DecodeSSZStrict(buf, version) +} +func (r *rawBuilderRequestAuth) DecodeSSZStrict(buf []byte, version int) error { + return ssz2.UnmarshalSSZStrict(buf, version, &rawByteList{value: &r.data, limit: MaxBuilderAuthDataSize}, &r.slot) +} +func (r *rawBuilderRequestAuth) Clone() clonable.Clonable { return new(rawBuilderRequestAuth) } + +type rawSignedBuilderRequestAuth struct { + message *rawBuilderRequestAuth + signature common.Bytes96 +} + +func (r *rawSignedBuilderRequestAuth) Static() bool { return false } +func (r *rawSignedBuilderRequestAuth) EncodingSizeSSZ() int { + if r.message == nil { + return 4 + len(r.signature) + } + return 4 + len(r.signature) + r.message.EncodingSizeSSZ() +} +func (r *rawSignedBuilderRequestAuth) EncodeSSZ(dst []byte) ([]byte, error) { + return ssz2.MarshalSSZ(dst, r.message, r.signature[:]) +} +func (r *rawSignedBuilderRequestAuth) DecodeSSZ(buf []byte, version int) error { + return r.DecodeSSZStrict(buf, version) +} +func (r *rawSignedBuilderRequestAuth) DecodeSSZStrict(buf []byte, version int) error { + r.message = new(rawBuilderRequestAuth) + return ssz2.UnmarshalSSZStrict(buf, version, r.message, r.signature[:]) +} +func (r *rawSignedBuilderRequestAuth) Clone() clonable.Clonable { + return new(rawSignedBuilderRequestAuth) +} + +type rawBuilderEntry struct{ entry *BuilderEntry } + +func (r *rawBuilderEntry) Static() bool { return false } +func (r *rawBuilderEntry) EncodingSizeSSZ() int { + if r.entry == nil { + return 0 + } + return r.entry.EncodingSizeSSZ() +} +func (r *rawBuilderEntry) EncodeSSZ(dst []byte) ([]byte, error) { + if r.entry == nil || r.entry.Auth == nil || r.entry.Auth.Message == nil { + return nil, errors.New("raw builder entry has nil auth") + } + urlBytes := hexutil.Bytes(r.entry.URL) + auth := &rawSignedBuilderRequestAuth{ + message: &rawBuilderRequestAuth{data: r.entry.Auth.Message.Data, slot: r.entry.Auth.Message.Slot}, + signature: r.entry.Auth.Signature, + } + return ssz2.MarshalSSZ(dst, + &rawByteList{value: &urlBytes, limit: MaxBuilderURLSize}, + auth, + &builderPubkeyList{values: &r.entry.BuilderPubkeys}, + r.entry.MaxExecutionPayment, + r.entry.MinBid, + r.entry.BuilderBoostFactor, + ) +} +func (r *rawBuilderEntry) DecodeSSZ(buf []byte, version int) error { + return r.DecodeSSZStrict(buf, version) +} +func (r *rawBuilderEntry) DecodeSSZStrict(buf []byte, version int) error { + var urlBytes hexutil.Bytes + var auth rawSignedBuilderRequestAuth + var pubkeys []common.Bytes48 + entry := new(BuilderEntry) + if err := ssz2.UnmarshalSSZStrict(buf, version, + &rawByteList{value: &urlBytes, limit: MaxBuilderURLSize}, + &auth, + &builderPubkeyList{values: &pubkeys}, + &entry.MaxExecutionPayment, + &entry.MinBid, + &entry.BuilderBoostFactor, + ); err != nil { + return err + } + entry.URL = string(urlBytes) + entry.BuilderPubkeys = pubkeys + entry.Auth = &SignedBuilderRequestAuth{ + Message: &BuilderRequestAuth{Data: auth.message.data, Slot: auth.message.slot}, + Signature: auth.signature, + } + r.entry = entry + return nil +} +func (r *rawBuilderEntry) Clone() clonable.Clonable { return new(rawBuilderEntry) } + +type rawBuilderEntryList struct{ values *[]*rawBuilderEntry } + +func (l *rawBuilderEntryList) Static() bool { return false } +func (l *rawBuilderEntryList) EncodingSizeSSZ() int { + size := len(*l.values) * 4 + for _, entry := range *l.values { + size += entry.EncodingSizeSSZ() + } + return size +} +func (l *rawBuilderEntryList) EncodeSSZ(dst []byte) ([]byte, error) { + return commonssz.EncodeDynamicList(dst, *l.values) +} +func (l *rawBuilderEntryList) DecodeSSZ(buf []byte, version int) error { + return l.DecodeSSZStrict(buf, version) +} +func (l *rawBuilderEntryList) DecodeSSZStrict(buf []byte, version int) error { + values, err := commonssz.DecodeDynamicListStrict[*rawBuilderEntry](buf, 0, uint32(len(buf)), MaxBuilderEntries, version) + if err != nil { + return err + } + *l.values = values + return nil +} +func (l *rawBuilderEntryList) Clone() clonable.Clonable { + values := []*rawBuilderEntry(nil) + return &rawBuilderEntryList{values: &values} +} func (l *builderEntryList) EncodeSSZ(dst []byte) ([]byte, error) { if len(*l.values) > MaxBuilderEntries { return nil, fmt.Errorf("builder count %d exceeds %d", len(*l.values), MaxBuilderEntries) diff --git a/cl/cltypes/epbs_builder_api_test.go b/cl/cltypes/epbs_builder_api_test.go index 697aadf1cb6..254d6aa92d9 100644 --- a/cl/cltypes/epbs_builder_api_test.go +++ b/cl/cltypes/epbs_builder_api_test.go @@ -23,6 +23,7 @@ import ( "github.com/stretchr/testify/require" + ssz2 "github.com/erigontech/erigon/cl/ssz" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" ) @@ -157,6 +158,25 @@ func TestBuilderConfigAndPreferencesLimits(t *testing.T) { require.Error(t, json.Unmarshal([]byte(`{"preferences":null,"auth":null}`), &decodedPrefs)) } +func TestBuilderConfigStructuralSSZDecodeIsolatesSemanticEntryErrors(t *testing.T) { + entries := []*rawBuilderEntry{ + {entry: &BuilderEntry{URL: "ftp:////builder.example", Auth: &SignedBuilderRequestAuth{Message: &BuilderRequestAuth{Data: []byte("auth"), Slot: 12}}}}, + {entry: &BuilderEntry{URL: "https://empty-auth.example", Auth: &SignedBuilderRequestAuth{Message: &BuilderRequestAuth{Slot: 12}}}}, + {entry: &BuilderEntry{URL: "https://builder.example", Auth: validSignedBuilderRequestAuth()}}, + } + encoded, err := ssz2.MarshalSSZ(nil, uint64(0), uint64(100), &rawBuilderEntryList{values: &entries}) + require.NoError(t, err) + + var strict BuilderConfig + require.Error(t, strict.DecodeSSZStrict(encoded, 0)) + var structural BuilderConfig + require.NoError(t, structural.DecodeSSZStrictStructural(encoded, 0)) + require.Len(t, structural.Builders, 3) + require.Error(t, structural.Builders[0].Validate()) + require.Error(t, structural.Builders[1].Validate()) + require.NoError(t, structural.Builders[2].Validate()) +} + func TestBuilderPreferencesEntryRejectsEmptyURL(t *testing.T) { entry := &BuilderPreferencesEntry{Auth: validSignedBuilderRequestAuth()} _, err := entry.EncodeSSZ(nil) diff --git a/cl/cltypes/epbs_payload.go b/cl/cltypes/epbs_payload.go index af375dd66ba..8db9533558e 100644 --- a/cl/cltypes/epbs_payload.go +++ b/cl/cltypes/epbs_payload.go @@ -113,6 +113,10 @@ func (p *PayloadAttestationData) DecodeSSZ(buf []byte, version int) error { return ssz2.UnmarshalSSZ(buf, version, p.BeaconBlockRoot[:], &p.Slot, &p.PayloadPresent, &p.BlobDataAvailable) } +func (p *PayloadAttestationData) DecodeSSZStrict(buf []byte, version int) error { + return ssz2.UnmarshalSSZStrict(buf, version, p.BeaconBlockRoot[:], &p.Slot, &p.PayloadPresent, &p.BlobDataAvailable) +} + func (p *PayloadAttestationData) Clone() clonable.Clonable { return &PayloadAttestationData{ BeaconBlockRoot: p.BeaconBlockRoot, @@ -152,6 +156,14 @@ func (p *PayloadAttestation) EncodeSSZ(buf []byte) ([]byte, error) { } func (p *PayloadAttestation) DecodeSSZ(buf []byte, version int) error { + return p.decodeSSZ(buf, version, false) +} + +func (p *PayloadAttestation) DecodeSSZStrict(buf []byte, version int) error { + return p.decodeSSZ(buf, version, true) +} + +func (p *PayloadAttestation) decodeSSZ(buf []byte, version int, strict bool) error { // Infer PTC_SIZE from the buffer length. The fixed structure is: // AggregationBits (PTC_SIZE/8) + PayloadAttestationData (42) + Signature (96) // so PTC_SIZE = (len(buf) - 42 - 96) * 8 @@ -162,6 +174,9 @@ func (p *PayloadAttestation) DecodeSSZ(buf []byte, version int) error { } p.AggregationBits = solid.NewBitVector(ptcSize) p.Data = new(PayloadAttestationData) + if strict { + return ssz2.UnmarshalSSZStrict(buf, version, p.AggregationBits, p.Data, p.Signature[:]) + } return ssz2.UnmarshalSSZ(buf, version, p.AggregationBits, p.Data, p.Signature[:]) } @@ -200,6 +215,11 @@ func (p *PayloadAttestationMessage) DecodeSSZ(buf []byte, version int) error { return ssz2.UnmarshalSSZ(buf, version, &p.ValidatorIndex, p.Data, p.Signature[:]) } +func (p *PayloadAttestationMessage) DecodeSSZStrict(buf []byte, version int) error { + p.Data = new(PayloadAttestationData) + return ssz2.UnmarshalSSZStrict(buf, version, &p.ValidatorIndex, p.Data, p.Signature[:]) +} + func (p *PayloadAttestationMessage) HashSSZ() ([32]byte, error) { return merkle_tree.HashTreeRoot(p.ValidatorIndex, p.Data, p.Signature[:]) } diff --git a/cl/cltypes/epbs_proposer.go b/cl/cltypes/epbs_proposer.go index da19b0b435b..98c418a433c 100644 --- a/cl/cltypes/epbs_proposer.go +++ b/cl/cltypes/epbs_proposer.go @@ -70,6 +70,10 @@ func (p *ProposerPreferences) DecodeSSZ(buf []byte, version int) error { return ssz2.UnmarshalSSZ(buf, version, p.DependentRoot[:], &p.ProposalSlot, &p.ValidatorIndex, p.FeeRecipient[:], &p.TargetGasLimit) } +func (p *ProposerPreferences) DecodeSSZStrict(buf []byte, version int) error { + return ssz2.UnmarshalSSZStrict(buf, version, p.DependentRoot[:], &p.ProposalSlot, &p.ValidatorIndex, p.FeeRecipient[:], &p.TargetGasLimit) +} + func (p *ProposerPreferences) Clone() clonable.Clonable { return &ProposerPreferences{ DependentRoot: p.DependentRoot, @@ -107,6 +111,11 @@ func (s *SignedProposerPreferences) DecodeSSZ(buf []byte, version int) error { return ssz2.UnmarshalSSZ(buf, version, s.Message, s.Signature[:]) } +func (s *SignedProposerPreferences) DecodeSSZStrict(buf []byte, version int) error { + s.Message = new(ProposerPreferences) + return ssz2.UnmarshalSSZStrict(buf, version, s.Message, s.Signature[:]) +} + func (s *SignedProposerPreferences) HashSSZ() ([32]byte, error) { return merkle_tree.HashTreeRoot(s.Message, s.Signature[:]) } diff --git a/cl/cltypes/indexed_attestation.go b/cl/cltypes/indexed_attestation.go index a68f4f9f1df..56823e8b71a 100644 --- a/cl/cltypes/indexed_attestation.go +++ b/cl/cltypes/indexed_attestation.go @@ -123,9 +123,21 @@ func (i *IndexedAttestation) DecodeSSZ(buf []byte, version int) error { return i.DecodeSSZWithConfig(buf, version, nil) } +func (i *IndexedAttestation) DecodeSSZStrict(buf []byte, version int) error { + return i.DecodeSSZStrictWithConfig(buf, version, nil) +} + // DecodeSSZWithConfig ssz unmarshals the IndexedAttestation object with preset-aware limits. // If cfg is nil, mainnet defaults are used. func (i *IndexedAttestation) DecodeSSZWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig) error { + return i.decodeSSZWithConfig(buf, version, cfg, false) +} + +func (i *IndexedAttestation) DecodeSSZStrictWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig) error { + return i.decodeSSZWithConfig(buf, version, cfg, true) +} + +func (i *IndexedAttestation) decodeSSZWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig, strict bool) error { i.version = clparams.StateVersion(version) i.Data = &solid.AttestationData{} if version >= int(clparams.ElectraVersion) { @@ -138,6 +150,9 @@ func (i *IndexedAttestation) DecodeSSZWithConfig(buf []byte, version int, cfg *c i.AttestingIndices = solid.NewRawUint64List(attestingIndicesLimit, nil) } + if strict { + return ssz2.UnmarshalSSZStrict(buf, version, i.AttestingIndices, i.Data, i.Signature[:]) + } return ssz2.UnmarshalSSZ(buf, version, i.AttestingIndices, i.Data, i.Signature[:]) } diff --git a/cl/cltypes/slashings.go b/cl/cltypes/slashings.go index ca5276b989d..fb8363ade2f 100644 --- a/cl/cltypes/slashings.go +++ b/cl/cltypes/slashings.go @@ -76,10 +76,25 @@ func (a *AttesterSlashing) DecodeSSZ(buf []byte, version int) error { return a.DecodeSSZWithConfig(buf, version, nil) } +func (a *AttesterSlashing) DecodeSSZStrict(buf []byte, version int) error { + return a.DecodeSSZStrictWithConfig(buf, version, nil) +} + // DecodeSSZWithConfig decodes an AttesterSlashing with preset-aware limits. func (a *AttesterSlashing) DecodeSSZWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig) error { + return a.decodeSSZWithConfig(buf, version, cfg, false) +} + +func (a *AttesterSlashing) DecodeSSZStrictWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig) error { + return a.decodeSSZWithConfig(buf, version, cfg, true) +} + +func (a *AttesterSlashing) decodeSSZWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig, strict bool) error { a.Attestation_1 = NewIndexedAttestationWithConfig(clparams.StateVersion(version), cfg) a.Attestation_2 = NewIndexedAttestationWithConfig(clparams.StateVersion(version), cfg) + if strict { + return ssz2.UnmarshalSSZStrict(buf, version, a.Attestation_1, a.Attestation_2) + } return ssz2.UnmarshalSSZ(buf, version, a.Attestation_1, a.Attestation_2) } diff --git a/cl/cltypes/solid/attestation.go b/cl/cltypes/solid/attestation.go index d394f03b2c5..22cc47453fb 100644 --- a/cl/cltypes/solid/attestation.go +++ b/cl/cltypes/solid/attestation.go @@ -133,8 +133,20 @@ func (a *Attestation) DecodeSSZ(buf []byte, version int) error { return a.DecodeSSZWithConfig(buf, version, nil) } +func (a *Attestation) DecodeSSZStrict(buf []byte, version int) error { + return a.DecodeSSZStrictWithConfig(buf, version, nil) +} + // DecodeSSZWithConfig decodes the provided buffer using cfg when it is available. func (a *Attestation) DecodeSSZWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig) error { + return a.decodeSSZWithConfig(buf, version, cfg, false) +} + +func (a *Attestation) DecodeSSZStrictWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig) error { + return a.decodeSSZWithConfig(buf, version, cfg, true) +} + +func (a *Attestation) decodeSSZWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig, strict bool) error { clversion := clparams.StateVersion(version) a.version = clversion if clversion.AfterOrEqual(clparams.ElectraVersion) { @@ -166,6 +178,9 @@ func (a *Attestation) DecodeSSZWithConfig(buf []byte, version int, cfg *clparams a.AggregationBits = NewBitList(0, aggrBitsLimit) a.Data = &AttestationData{} a.CommitteeBits = NewBitVector(committeeBitsLimit) + if strict { + return ssz2.UnmarshalSSZStrict(buf, version, a.AggregationBits, a.Data, a.Signature[:], a.CommitteeBits) + } return ssz2.UnmarshalSSZ(buf, version, a.AggregationBits, a.Data, a.Signature[:], a.CommitteeBits) } @@ -175,6 +190,9 @@ func (a *Attestation) DecodeSSZWithConfig(buf []byte, version int, cfg *clparams } a.AggregationBits = NewBitList(0, aggregationBitsSizeDeneb) a.Data = &AttestationData{} + if strict { + return ssz2.UnmarshalSSZStrict(buf, version, a.AggregationBits, a.Data, a.Signature[:]) + } return ssz2.UnmarshalSSZ(buf, version, a.AggregationBits, a.Data, a.Signature[:]) } diff --git a/cl/cltypes/solid/attestation_config_test.go b/cl/cltypes/solid/attestation_config_test.go index 19cf12d9e9e..16d68a9eaba 100644 --- a/cl/cltypes/solid/attestation_config_test.go +++ b/cl/cltypes/solid/attestation_config_test.go @@ -48,3 +48,42 @@ func TestAttestationValidateForConfigNormalizesJSONCommitteeBits(t *testing.T) { attestation.CommitteeBits = tooLong require.Error(t, attestation.ValidateForConfig(&cfg, clparams.GloasVersion)) } + +func TestAttestationDecodeSSZStrictRejectsOffsetGap(t *testing.T) { + tests := []struct { + name string + version clparams.StateVersion + value *Attestation + }{ + { + name: "deneb", + version: clparams.DenebVersion, + value: &Attestation{ + AggregationBits: BitlistFromBytes([]byte{1}, maxValidatorsPerCommittee), + Data: &AttestationData{}, + }, + }, + { + name: "gloas", + version: clparams.GloasVersion, + value: &Attestation{ + AggregationBits: BitlistFromBytes([]byte{1}, aggregationBitsSizeElectra), + Data: &AttestationData{}, + CommitteeBits: NewBitVector(int(clparams.MainnetBeaconConfig.MaxCommitteesPerSlot)), + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + encoded, err := test.value.EncodeSSZ(nil) + require.NoError(t, err) + require.NoError(t, new(Attestation).DecodeSSZStrictWithConfig(encoded, int(test.version), &clparams.MainnetBeaconConfig)) + fixedSize := int(binary.LittleEndian.Uint32(encoded[:4])) + malformed := append([]byte(nil), encoded[:fixedSize]...) + malformed = append(malformed, make([]byte, 4)...) + malformed = append(malformed, encoded[fixedSize:]...) + binary.LittleEndian.PutUint32(malformed, uint32(fixedSize+4)) + require.Error(t, new(Attestation).DecodeSSZStrictWithConfig(malformed, int(test.version), &clparams.MainnetBeaconConfig)) + }) + } +} diff --git a/cl/cltypes/strict_attestation_test.go b/cl/cltypes/strict_attestation_test.go new file mode 100644 index 00000000000..1c6ed5aca76 --- /dev/null +++ b/cl/cltypes/strict_attestation_test.go @@ -0,0 +1,43 @@ +package cltypes + +import ( + "encoding/binary" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cl/clparams" +) + +func TestIndexedAttestationDecodeSSZStrictRejectsOffsetGap(t *testing.T) { + attestation := NewIndexedAttestation(clparams.GloasVersion) + attestation.AttestingIndices.Append(1) + encoded, err := attestation.EncodeSSZ(nil) + require.NoError(t, err) + require.NoError(t, NewIndexedAttestation(clparams.GloasVersion).DecodeSSZStrict(encoded, int(clparams.GloasVersion))) + + const fixedSize = 228 + malformed := append([]byte(nil), encoded[:fixedSize]...) + malformed = append(malformed, make([]byte, 4)...) + malformed = append(malformed, encoded[fixedSize:]...) + binary.LittleEndian.PutUint32(malformed, fixedSize+4) + require.Error(t, NewIndexedAttestation(clparams.GloasVersion).DecodeSSZStrict(malformed, int(clparams.GloasVersion))) +} + +func TestAttesterSlashingDecodeSSZStrictRejectsNestedIndexedGap(t *testing.T) { + slashing := NewAttesterSlashing(clparams.GloasVersion) + slashing.Attestation_1.AttestingIndices.Append(1) + encoded, err := slashing.EncodeSSZ(nil) + require.NoError(t, err) + require.NoError(t, NewAttesterSlashing(clparams.GloasVersion).DecodeSSZStrict(encoded, int(clparams.GloasVersion))) + + const outerFixedSize = 8 + const indexedFixedSize = 228 + insertAt := outerFixedSize + indexedFixedSize + malformed := append([]byte(nil), encoded[:insertAt]...) + malformed = append(malformed, make([]byte, 4)...) + malformed = append(malformed, encoded[insertAt:]...) + binary.LittleEndian.PutUint32(malformed[4:], binary.LittleEndian.Uint32(encoded[4:])+4) + binary.LittleEndian.PutUint32(malformed[outerFixedSize:], indexedFixedSize+4) + require.Error(t, NewAttesterSlashing(clparams.GloasVersion).DecodeSSZStrict(malformed, int(clparams.GloasVersion))) +} diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go b/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go index 1282067ca1a..7938262f2c8 100644 --- a/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go +++ b/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go @@ -143,7 +143,8 @@ type forkGraphDisk struct { lightClientUpdates sync.Map // period -> lightclientupdate // in-memory cache of block roots that have envelopes on disk [Optimization for Gloas:EIP7732] - envelopeExists sync.Map // common.Hash -> struct{} + envelopeExists sync.Map // common.Hash -> struct{} + envelopeMissing sync.Map // common.Hash -> struct{} // reusable buffers sszBuffer []byte @@ -445,7 +446,7 @@ func (f *forkGraphDisk) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint6 } func (f *forkGraphDisk) HasBlockEquivocation(slot, proposerIndex uint64, exceptRoot common.Hash) bool { - if slot < f.LowestAvailableSlot() { + if isBelowPrunedBoundary(slot, f.lowestAvailableBlock.Load()) { return false } found := false @@ -480,10 +481,6 @@ func (f *forkGraphDisk) addValidatedChild(parentRoot, childRoot common.Hash, slo } } -func (f *forkGraphDisk) removeValidatedChild(parentRoot, childRoot common.Hash) { - f.removeValidatedChildren(map[common.Hash][]common.Hash{parentRoot: {childRoot}}) -} - func (f *forkGraphDisk) removeValidatedChildren(rootsByParent map[common.Hash][]common.Hash) { f.childrenMu.Lock() defer f.childrenMu.Unlock() @@ -783,6 +780,7 @@ func (f *forkGraphDisk) Prune(pruneSlot uint64) (err error) { f.headers.Delete(root) f.blockRewards.Delete(root) f.envelopeExists.Delete(root) + f.envelopeMissing.Delete(root) f.unavailablePayloads.Delete(root) f.acceptedPayloads.Delete(root) f.badBlocks.Delete(root) diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go b/cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go index c92c13f523a..293b197c97e 100644 --- a/cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go +++ b/cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go @@ -191,17 +191,26 @@ func (f *forkGraphDisk) HasEnvelope(blockRoot common.Hash) bool { if _, ok := f.envelopeExists.Load(blockRoot); ok { return true } + if _, ok := f.envelopeMissing.Load(blockRoot); ok { + return false + } f.stateDumpLock.Lock() defer f.stateDumpLock.Unlock() if _, ok := f.envelopeExists.Load(blockRoot); ok { return true } + if _, ok := f.envelopeMissing.Load(blockRoot); ok { + return false + } // Slow path: fall back to disk and populate cache on hit exists, err := afero.Exists(f.fs, getEnvelopeFilename(blockRoot)) if err == nil && exists { f.envelopeExists.Store(blockRoot, struct{}{}) return true } + if err == nil { + f.envelopeMissing.Store(blockRoot, struct{}{}) + } return false } @@ -336,6 +345,7 @@ func (f *forkGraphDisk) DumpEnvelopeOnDisk(blockRoot common.Hash, envelope *clty return } f.envelopeExists.Store(blockRoot, struct{}{}) + f.envelopeMissing.Delete(blockRoot) return } diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_test.go b/cl/phase1/forkchoice/fork_graph/fork_graph_test.go index e7074bf9150..53156064f9b 100644 --- a/cl/phase1/forkchoice/fork_graph/fork_graph_test.go +++ b/cl/phase1/forkchoice/fork_graph/fork_graph_test.go @@ -76,6 +76,19 @@ type partialWriteFs struct { fail bool } +type countingStatFs struct { + afero.Fs + mu sync.Mutex + stats int +} + +func (fs *countingStatFs) Stat(name string) (os.FileInfo, error) { + fs.mu.Lock() + fs.stats++ + fs.mu.Unlock() + return fs.Fs.Stat(name) +} + type blockingRenameFs struct { afero.Fs target string @@ -189,6 +202,24 @@ func TestDumpEnvelopeErrorDoesNotPublishPartialFile(t *testing.T) { require.NoError(t, err) } +func TestHasEnvelopeCachesMissAndDumpInvalidatesMiss(t *testing.T) { + fs := &countingStatFs{Fs: afero.NewMemMapFs()} + cfg := clparams.MainnetBeaconConfig + f := &forkGraphDisk{fs: fs, beaconCfg: &cfg} + root := common.Hash{1} + f.headers.Store(root, &cltypes.BeaconBlockHeader{Slot: 1}) + + require.False(t, f.HasEnvelope(root)) + require.False(t, f.HasEnvelope(root)) + fs.mu.Lock() + require.Equal(t, 1, fs.stats) + fs.mu.Unlock() + + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&cfg)} + require.NoError(t, f.DumpEnvelopeOnDisk(root, envelope)) + require.True(t, f.HasEnvelope(root)) +} + func TestDumpEnvelopeBeforePruneDoesNotSurvivePrune(t *testing.T) { baseFs := afero.NewMemMapFs() oldRoot := common.Hash{1} @@ -332,7 +363,7 @@ func TestHasBlockChildAtOrAfterUsesValidatedChildren(t *testing.T) { require.True(t, f.HasBlockChildAtOrAfter(parentRoot, 64)) require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 65)) require.False(t, f.HasBlockChildAtOrAfter(common.Hash{3}, 64)) - f.removeValidatedChild(parentRoot, common.Hash{2}) + f.removeValidatedChildren(map[common.Hash][]common.Hash{parentRoot: {{2}}}) require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 64)) } @@ -349,6 +380,22 @@ func TestHasBlockEquivocation(t *testing.T) { require.False(t, f.HasBlockEquivocation(64, 8, common.Hash{2})) } +func TestHasBlockEquivocationUsesPruneBoundary(t *testing.T) { + f := &forkGraphDisk{} + retained := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + retained.Block.Slot = 64 + retained.Block.ProposerIndex = 9 + pruned := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + pruned.Block.Slot = 63 + pruned.Block.ProposerIndex = 9 + f.blocks.Store(common.Hash{1}, retained) + f.blocks.Store(common.Hash{2}, pruned) + f.lowestAvailableBlock.Store(65) + + require.True(t, f.HasBlockEquivocation(64, 9, common.Hash{3})) + require.False(t, f.HasBlockEquivocation(63, 9, common.Hash{3})) +} + func TestRemoveValidatedChildrenBulkKeepsSameSlotSurvivor(t *testing.T) { f := &forkGraphDisk{children: make(map[common.Hash]*validatedChildren)} parentRoot := common.Hash{1} diff --git a/cl/phase1/forkchoice/forkchoice.go b/cl/phase1/forkchoice/forkchoice.go index 09e1f96c0c2..b860a92bd4e 100644 --- a/cl/phase1/forkchoice/forkchoice.go +++ b/cl/phase1/forkchoice/forkchoice.go @@ -797,19 +797,14 @@ func (f *ForkChoiceStore) HasEnvelope(blockRoot common.Hash) bool { // has been accepted by the execution layer. // [New in Gloas:EIP7732] func (f *ForkChoiceStore) IsPayloadVerified(blockRoot common.Hash) bool { - if f.forkGraph == nil || !f.forkGraph.HasEnvelope(blockRoot) { + if f.forkGraph == nil { return false } - if f.forkGraph != nil { - if verified, accepted := f.forkGraph.PayloadAccepted(blockRoot); accepted { - return verified - } + verified, accepted := f.forkGraph.PayloadAccepted(blockRoot) + if !accepted || !verified { return false } - if f.verifiedExecutionPayload == nil { - return false - } - return f.verifiedExecutionPayload.Contains(blockRoot) + return f.forkGraph.HasEnvelope(blockRoot) } func (f *ForkChoiceStore) MarkPayloadVerified(blockRoot common.Hash, executionBlockHash common.Hash) { diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index 291487ed206..687e537b24c 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -118,7 +118,7 @@ func (b *blockService) IsMyGossipMessage(name string) bool { func (b *blockService) DecodeGossipMessage(_ peer.ID, data []byte, version clparams.StateVersion) (*cltypes.SignedBeaconBlock, error) { obj := cltypes.NewSignedBeaconBlock(b.beaconCfg, version) - if err := obj.DecodeSSZ(data, int(version)); err != nil { + if err := obj.DecodeSSZStrict(data, int(version)); err != nil { return nil, err } return obj, nil @@ -304,6 +304,16 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac if currentSlot < msg.Block.Slot && !b.ethClock.IsSlotCurrentSlotWithMaximumClockDisparity(msg.Block.Slot) { return fmt.Errorf("%w: block is not from a future slot: %d > %d", ErrIgnore, currentSlot, msg.Block.Slot) } + if b.beaconCfg.SlotsPerEpoch == 0 { + return errors.New("slots per epoch is zero") + } + epoch := msg.Block.Slot / b.beaconCfg.SlotsPerEpoch + blockVersion := b.beaconCfg.GetCurrentStateVersion(epoch) + if blockVersion >= clparams.GloasVersion { + if err := validateGloasBlockBodyLimits(b.beaconCfg, msg.Block.Body); err != nil { + return err + } + } var finalizedCheckpoint solid.Checkpoint if err := b.syncedData.ViewHeadState(func(headState *state.CachingBeaconState) error { @@ -342,8 +352,6 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac if parentHeader.Slot >= msg.Block.Slot { return ErrBlockYoungerThanParent } - epoch := msg.Block.Slot / b.beaconCfg.SlotsPerEpoch - blockVersion := b.beaconCfg.GetCurrentStateVersion(epoch) var gloasBid *cltypes.ExecutionPayloadBid parentIsFull := false if blockVersion >= clparams.GloasVersion { @@ -428,6 +436,64 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac return nil } +func validateGloasBlockBodyLimits(cfg *clparams.BeaconChainConfig, body *cltypes.BeaconBody) error { + if cfg == nil || body == nil { + return errors.New("missing Gloas block body configuration") + } + if body.ProposerSlashings == nil || body.AttesterSlashings == nil || body.Attestations == nil || body.Deposits == nil || + body.VoluntaryExits == nil || body.ExecutionChanges == nil || body.PayloadAttestations == nil { + return errors.New("missing Gloas block body operation list") + } + checks := []struct { + name string + count int + limit uint64 + }{ + {"proposer slashings", body.ProposerSlashings.Len(), cfg.MaxProposerSlashings}, + {"attester slashings", body.AttesterSlashings.Len(), cfg.MaxAttesterSlashingsElectra}, + {"attestations", body.Attestations.Len(), cfg.MaxAttestationsElectra}, + {"voluntary exits", body.VoluntaryExits.Len(), cfg.MaxVoluntaryExits}, + {"BLS to execution changes", body.ExecutionChanges.Len(), cfg.MaxBlsToExecutionChanges}, + {"payload attestations", body.PayloadAttestations.Len(), cfg.MaxPayloadAttestations}, + } + if body.Deposits.Len() != 0 { + return fmt.Errorf("deposits count %d exceeds Gloas limit 0", body.Deposits.Len()) + } + for _, check := range checks { + if uint64(check.count) > check.limit { + return fmt.Errorf("%s count %d exceeds limit %d", check.name, check.count, check.limit) + } + } + return validateExecutionRequestsLimits(cfg, body.ParentExecutionRequests) +} + +func validateExecutionRequestsLimits(cfg *clparams.BeaconChainConfig, requests *cltypes.ExecutionRequests) error { + if cfg == nil || requests == nil { + return errors.New("missing execution requests") + } + if requests.Deposits == nil || requests.Withdrawals == nil || requests.Consolidations == nil || + requests.BuilderDeposits == nil || requests.BuilderExits == nil { + return errors.New("missing execution request list") + } + checks := []struct { + name string + count int + limit uint64 + }{ + {"deposit requests", requests.Deposits.Len(), cfg.MaxDepositRequestsPerPayload}, + {"withdrawal requests", requests.Withdrawals.Len(), cfg.MaxWithdrawalRequestsPerPayload}, + {"consolidation requests", requests.Consolidations.Len(), cfg.MaxConsolidationRequestsPerPayload}, + {"builder deposit requests", requests.BuilderDeposits.Len(), cfg.MaxBuilderDepositRequestsPerPayload}, + {"builder exit requests", requests.BuilderExits.Len(), cfg.MaxBuilderExitRequestsPerPayload}, + } + for _, check := range checks { + if uint64(check.count) > check.limit { + return fmt.Errorf("%s count %d exceeds limit %d", check.name, check.count, check.limit) + } + } + return nil +} + // publishBlockGossipEvent publishes a block event which has not been processed yet func (b *blockService) publishBlockGossipEvent(block *cltypes.SignedBeaconBlock) { if b.emitter == nil { diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index ff7dd15ca6a..81606c578fb 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -19,6 +19,7 @@ package services import ( "bytes" "context" + "encoding/binary" "errors" "sync" "testing" @@ -575,3 +576,62 @@ func TestImportBlockOperationsAttesterSlashingLogging(t *testing.T) { // - TestBlockServiceGloasSuccess // // For now, the GLOAS validation code path is verified by code review and integration tests. + +func TestValidateGloasBlockBodyLimitsRejectsOversizedOperationAndRequests(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + cfg.MaxProposerSlashings = 1 + cfg.MaxBuilderDepositRequestsPerPayload = 1 + body := cltypes.NewBeaconBody(&cfg, clparams.GloasVersion) + body.ProposerSlashings.Append(&cltypes.ProposerSlashing{}) + body.ProposerSlashings.Append(&cltypes.ProposerSlashing{}) + require.Error(t, validateGloasBlockBodyLimits(&cfg, body)) + + body = cltypes.NewBeaconBody(&cfg, clparams.GloasVersion) + body.ParentExecutionRequests.BuilderDeposits.Append(&solid.BuilderDepositRequest{}) + body.ParentExecutionRequests.BuilderDeposits.Append(&solid.BuilderDepositRequest{}) + require.Error(t, validateGloasBlockBodyLimits(&cfg, body)) +} + +func TestValidateGloasBlockBodyLimitsRejectsDeposit(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + body := cltypes.NewBeaconBody(&cfg, clparams.GloasVersion) + require.NoError(t, validateGloasBlockBodyLimits(&cfg, body)) + body.Deposits.Append(&cltypes.Deposit{}) + require.ErrorContains(t, validateGloasBlockBodyLimits(&cfg, body), "deposits") +} + +func TestBlockServiceDecodeGossipMessageStrict(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + service := &blockService{beaconCfg: &cfg} + block := cltypes.NewSignedBeaconBlock(&cfg, clparams.GloasVersion) + encoded, err := block.EncodeSSZ(nil) + require.NoError(t, err) + _, err = service.DecodeGossipMessage("peer", encoded, clparams.GloasVersion) + require.NoError(t, err) + + outerGap := append([]byte(nil), encoded[:100]...) + outerGap = append(outerGap, make([]byte, 4)...) + outerGap = append(outerGap, encoded[100:]...) + binary.LittleEndian.PutUint32(outerGap, 104) + _, err = service.DecodeGossipMessage("peer", outerGap, clparams.GloasVersion) + require.Error(t, err) + + const blockStart = 100 + const blockFixedSize = 84 + nestedGap := append([]byte(nil), encoded[:blockStart+blockFixedSize]...) + nestedGap = append(nestedGap, make([]byte, 4)...) + nestedGap = append(nestedGap, encoded[blockStart+blockFixedSize:]...) + binary.LittleEndian.PutUint32(nestedGap[blockStart+80:], blockFixedSize+4) + _, err = service.DecodeGossipMessage("peer", nestedGap, clparams.GloasVersion) + require.Error(t, err) +} + +func TestBlockServiceDecodeGossipMessageStrictPreGloasCompatibility(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + service := &blockService{beaconCfg: &cfg} + block := cltypes.NewSignedBeaconBlock(&cfg, clparams.DenebVersion) + encoded, err := block.EncodeSSZ(nil) + require.NoError(t, err) + _, err = service.DecodeGossipMessage("peer", encoded, clparams.DenebVersion) + require.NoError(t, err) +} diff --git a/cl/phase1/network/services/execution_payload_bid_service.go b/cl/phase1/network/services/execution_payload_bid_service.go index c6ce422a65d..2ed45aad6d7 100644 --- a/cl/phase1/network/services/execution_payload_bid_service.go +++ b/cl/phase1/network/services/execution_payload_bid_service.go @@ -186,7 +186,7 @@ func (s *executionPayloadBidService) Names() []string { func (s *executionPayloadBidService) DecodeGossipMessage(_ peer.ID, data []byte, version clparams.StateVersion) (*cltypes.SignedExecutionPayloadBid, error) { msg := &cltypes.SignedExecutionPayloadBid{} - if err := msg.DecodeSSZ(data, int(version)); err != nil { + if err := msg.DecodeSSZStrict(data, int(version)); err != nil { return nil, err } return msg, nil @@ -211,16 +211,16 @@ func (s *executionPayloadBidService) ProcessMessage(ctx context.Context, _ *uint "parentBlockHash", bid.ParentBlockHash) now := s.now() + // [IGNORE] bid.slot is the current or next slot + if !isCurrentOrNextSlot(s.ethClock, s.beaconCfg, now, slot, gloasMaximumClockDisparity) { + return fmt.Errorf("%w: bid slot %d is not current or next slot", ErrIgnore, slot) + } s.epbsPool.HighestBids.PruneSlots(func(entrySlot uint64) bool { return isPastBidWindow(s.ethClock, s.beaconCfg, now, entrySlot) }) s.epbsPool.ProposerPreferences.PruneSlots(func(entrySlot uint64) bool { return isPastBidWindow(s.ethClock, s.beaconCfg, now, entrySlot) }) - // [IGNORE] bid.slot is the current or next slot - if !isCurrentOrNextSlot(s.ethClock, s.beaconCfg, now, slot, gloasMaximumClockDisparity) { - return fmt.Errorf("%w: bid slot %d is not current or next slot", ErrIgnore, slot) - } seenKey := newSeenBidKey(bid) if s.seenCache.Contains(seenKey) { @@ -449,10 +449,6 @@ func (s *executionPayloadBidService) validateBidAuthentication(msg *cltypes.Sign return nil } -func (s *executionPayloadBidService) storeValidBid(msg *cltypes.SignedExecutionPayloadBid) error { - return s.storeValidBidAt(msg, time.Time{}) -} - func (s *executionPayloadBidService) storeValidBidAt(msg *cltypes.SignedExecutionPayloadBid, now time.Time) error { bid := msg.Message seenKey := newSeenBidKey(bid) diff --git a/cl/phase1/network/services/execution_payload_bid_service_test.go b/cl/phase1/network/services/execution_payload_bid_service_test.go index be3d0b0c184..0348e1e613e 100644 --- a/cl/phase1/network/services/execution_payload_bid_service_test.go +++ b/cl/phase1/network/services/execution_payload_bid_service_test.go @@ -2,6 +2,7 @@ package services import ( "context" + "encoding/binary" "errors" "math" "sync" @@ -870,8 +871,8 @@ func TestExecutionPayloadBidServiceStoreValidBidDoesNotOverwriteHigherBid(t *tes high := newTestSignedExecutionPayloadBid(100, 1, 2000) low := newTestSignedExecutionPayloadBid(100, 2, 500) - require.NoError(t, service.storeValidBid(high)) - err := service.storeValidBid(low) + require.NoError(t, service.storeValidBidAt(high, time.Time{})) + err := service.storeValidBidAt(low, time.Time{}) require.Error(t, err) require.True(t, errors.Is(err, ErrIgnore)) @@ -1235,6 +1236,24 @@ func TestExecutionPayloadBidServiceDecodeGossipMessageInvalid(t *testing.T) { require.Error(t, err) } +func TestExecutionPayloadBidServiceDecodeGossipMessageRejectsNonCanonicalOffsets(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl) + encoded, err := newTestSignedExecutionPayloadBid(100, 1, 1000).EncodeSSZ(nil) + require.NoError(t, err) + const signedFixedSize = 100 + const bidFixedSize = 224 + const commitmentsOffsetPosition = 188 + nonCanonical := append([]byte(nil), encoded[:signedFixedSize+bidFixedSize]...) + nonCanonical = append(nonCanonical, make([]byte, 4)...) + nonCanonical = append(nonCanonical, encoded[signedFixedSize+bidFixedSize:]...) + offset := signedFixedSize + commitmentsOffsetPosition + binary.LittleEndian.PutUint32(nonCanonical[offset:], binary.LittleEndian.Uint32(encoded[offset:])+4) + + _, err = service.DecodeGossipMessage("peer123", nonCanonical, clparams.GloasVersion) + require.Error(t, err) +} + func TestExecutionPayloadBidServiceNonZeroExecutionPayment(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/cl/phase1/network/services/execution_payload_service.go b/cl/phase1/network/services/execution_payload_service.go index 5936f59b53a..4a155bd7ad5 100644 --- a/cl/phase1/network/services/execution_payload_service.go +++ b/cl/phase1/network/services/execution_payload_service.go @@ -113,7 +113,7 @@ func (s *executionPayloadService) DecodeGossipMessage(_ peer.ID, data []byte, ve obj := &cltypes.SignedExecutionPayloadEnvelope{ Message: cltypes.NewExecutionPayloadEnvelope(s.beaconCfg), } - if err := obj.DecodeSSZ(data, int(version)); err != nil { + if err := obj.DecodeSSZStrict(data, int(version)); err != nil { return nil, err } return obj, nil @@ -128,6 +128,9 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64, } envelope := signedEnvelope.Message + if err := validateEnvelopeLimits(s.beaconCfg, envelope); err != nil { + return err + } beaconBlockRoot := envelope.BeaconBlockRoot builderIndex := envelope.BuilderIndex @@ -215,6 +218,22 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64, return nil } +func validateEnvelopeLimits(cfg *clparams.BeaconChainConfig, envelope *cltypes.ExecutionPayloadEnvelope) error { + if cfg == nil || envelope == nil || envelope.Payload == nil { + return errors.New("missing execution payload envelope fields") + } + if err := validateExecutionRequestsLimits(cfg, envelope.ExecutionRequests); err != nil { + return err + } + if envelope.Payload.Withdrawals == nil { + return errors.New("missing payload withdrawals") + } + if uint64(envelope.Payload.Withdrawals.Len()) > cfg.MaxWithdrawalsPerPayload { + return fmt.Errorf("payload withdrawals count %d exceeds limit %d", envelope.Payload.Withdrawals.Len(), cfg.MaxWithdrawalsPerPayload) + } + return nil +} + func (s *executionPayloadService) emitExecutionPayloadGossip(block *cltypes.SignedBeaconBlock, envelope *cltypes.ExecutionPayloadEnvelope) { s.emitters.Operation().SendExecutionPayloadGossip(&beaconevents.ExecutionPayloadGossipData{ Slot: block.Block.Slot, diff --git a/cl/phase1/network/services/execution_payload_service_test.go b/cl/phase1/network/services/execution_payload_service_test.go index dd749eb4e22..d53b79b35df 100644 --- a/cl/phase1/network/services/execution_payload_service_test.go +++ b/cl/phase1/network/services/execution_payload_service_test.go @@ -18,6 +18,7 @@ package services import ( "context" + "encoding/binary" "errors" "sync" "testing" @@ -52,6 +53,7 @@ func newTestSignedEnvelope(slot uint64, blockRoot common.Hash, builderIndex uint envelope.Payload.SlotNumber = slot envelope.Payload.Extra = solid.NewExtraData() envelope.Payload.Transactions = &solid.TransactionsSSZ{} + envelope.Payload.Withdrawals = solid.NewStaticListSSZ[*cltypes.Withdrawal](int(clparams.MainnetBeaconConfig.MaxWithdrawalsPerPayload), 44) } return &cltypes.SignedExecutionPayloadEnvelope{ Message: envelope, @@ -59,6 +61,23 @@ func newTestSignedEnvelope(slot uint64, blockRoot common.Hash, builderIndex uint } } +func TestExecutionPayloadServiceDecodeRejectsNonCanonicalOffsets(t *testing.T) { + service, _ := setupExecutionPayloadService(t) + encoded, err := newTestSignedEnvelope(100, common.Hash{1}, 1).EncodeSSZ(nil) + require.NoError(t, err) + const signedFixedSize = 100 + const envelopeFixedSize = 80 + nonCanonical := append([]byte(nil), encoded[:signedFixedSize+envelopeFixedSize]...) + nonCanonical = append(nonCanonical, make([]byte, 4)...) + nonCanonical = append(nonCanonical, encoded[signedFixedSize+envelopeFixedSize:]...) + for offset := signedFixedSize; offset < signedFixedSize+8; offset += 4 { + binary.LittleEndian.PutUint32(nonCanonical[offset:], binary.LittleEndian.Uint32(encoded[offset:])+4) + } + + _, err = service.DecodeGossipMessage("peer123", nonCanonical, clparams.GloasVersion) + require.Error(t, err) +} + func TestExecutionPayloadServiceNilEnvelope(t *testing.T) { service, _ := setupExecutionPayloadService(t) @@ -589,3 +608,28 @@ func TestExecutionPayloadServiceNames(t *testing.T) { require.True(t, impl.IsMyGossipMessage("execution_payload")) require.False(t, impl.IsMyGossipMessage("beacon_block")) } + +func TestValidateEnvelopeLimitsRejectsOversizedRequestsAndWithdrawals(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + cfg.MaxDepositRequestsPerPayload = 1 + cfg.MaxWithdrawalsPerPayload = 1 + envelope := cltypes.NewExecutionPayloadEnvelope(&cfg) + envelope.ExecutionRequests.Deposits.Append(&solid.DepositRequest{}) + envelope.ExecutionRequests.Deposits.Append(&solid.DepositRequest{}) + require.Error(t, validateEnvelopeLimits(&cfg, envelope)) + + envelope = cltypes.NewExecutionPayloadEnvelope(&cfg) + envelope.Payload.Withdrawals = solid.NewStaticListSSZ[*cltypes.Withdrawal](16, 44) + envelope.Payload.Withdrawals.Append(&cltypes.Withdrawal{}) + envelope.Payload.Withdrawals.Append(&cltypes.Withdrawal{}) + require.Error(t, validateEnvelopeLimits(&cfg, envelope)) +} + +func TestValidateEnvelopeLimitsRequiresWithdrawalsList(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + envelope := cltypes.NewExecutionPayloadEnvelope(&cfg) + require.ErrorContains(t, validateEnvelopeLimits(&cfg, envelope), "missing payload withdrawals") + + envelope.Payload.Withdrawals = solid.NewStaticListSSZ[*cltypes.Withdrawal](int(cfg.MaxWithdrawalsPerPayload), 44) + require.NoError(t, validateEnvelopeLimits(&cfg, envelope)) +} diff --git a/cl/phase1/network/services/payload_attestation_service.go b/cl/phase1/network/services/payload_attestation_service.go index 20296ea4ef7..bc7b8545118 100644 --- a/cl/phase1/network/services/payload_attestation_service.go +++ b/cl/phase1/network/services/payload_attestation_service.go @@ -116,7 +116,7 @@ func (s *payloadAttestationService) Names() []string { func (s *payloadAttestationService) DecodeGossipMessage(_ peer.ID, data []byte, version clparams.StateVersion) (*cltypes.PayloadAttestationMessage, error) { msg := &cltypes.PayloadAttestationMessage{} - if err := msg.DecodeSSZ(data, int(version)); err != nil { + if err := msg.DecodeSSZStrict(data, int(version)); err != nil { return nil, err } return msg, nil diff --git a/cl/phase1/network/services/payload_attestation_service_test.go b/cl/phase1/network/services/payload_attestation_service_test.go index 3b6384aa246..d8f9b820e16 100644 --- a/cl/phase1/network/services/payload_attestation_service_test.go +++ b/cl/phase1/network/services/payload_attestation_service_test.go @@ -811,3 +811,19 @@ func TestPayloadAttestationServiceDecodeGossipMessageInvalid(t *testing.T) { _, err := service.DecodeGossipMessage("peer123", []byte{0x00, 0x01, 0x02}, clparams.GloasVersion) require.Error(t, err) } + +func TestPayloadAttestationServiceDecodeGossipMessageStrict(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, _ := setupPayloadAttestationService(t, ctrl) + encoded, err := newTestPayloadAttestationMessage(100, 42, common.Hash{1}).EncodeSSZ(nil) + require.NoError(t, err) + + _, err = service.DecodeGossipMessage("peer123", append(encoded, 0), clparams.GloasVersion) + require.Error(t, err) + + nonCanonicalBool := append([]byte(nil), encoded...) + const payloadPresentOffset = 8 + 32 + 8 + nonCanonicalBool[payloadPresentOffset] = 2 + _, err = service.DecodeGossipMessage("peer123", nonCanonicalBool, clparams.GloasVersion) + require.Error(t, err) +} diff --git a/cl/phase1/network/services/proposer_preferences_service.go b/cl/phase1/network/services/proposer_preferences_service.go index d133bb37222..1d5014d4823 100644 --- a/cl/phase1/network/services/proposer_preferences_service.go +++ b/cl/phase1/network/services/proposer_preferences_service.go @@ -69,7 +69,7 @@ func (s *proposerPreferencesService) Names() []string { func (s *proposerPreferencesService) DecodeGossipMessage(_ peer.ID, data []byte, version clparams.StateVersion) (*cltypes.SignedProposerPreferences, error) { msg := &cltypes.SignedProposerPreferences{} - if err := msg.DecodeSSZ(data, int(version)); err != nil { + if err := msg.DecodeSSZStrict(data, int(version)); err != nil { return nil, err } return msg, nil @@ -89,9 +89,6 @@ func (s *proposerPreferencesService) ProcessMessage(ctx context.Context, _ *uint "validatorIndex", validatorIndex) now := s.now() - s.epbsPool.ProposerPreferences.PruneSlots(func(entrySlot uint64) bool { - return isPastBidWindow(s.ethClock, s.beaconCfg, now, entrySlot) - }) past, validTime := isPastSlot(s.ethClock, s.beaconCfg, now, proposalSlot, gloasMaximumClockDisparity) if !validTime { return fmt.Errorf("%w: proposal slot %d has no representable time", ErrIgnore, proposalSlot) @@ -118,6 +115,9 @@ func (s *proposerPreferencesService) ProcessMessage(ctx context.Context, _ *uint if now.Add(gloasMaximumClockDisparity).Before(lookaheadEpochStartTime) { return fmt.Errorf("%w: proposer for proposal slot %d is not yet known", ErrIgnore, proposalSlot) } + s.epbsPool.ProposerPreferences.PruneSlots(func(entrySlot uint64) bool { + return isPastBidWindow(s.ethClock, s.beaconCfg, now, entrySlot) + }) // [IGNORE] First valid message for this dependent root and proposal slot. seenKey := newSeenProposerPreferencesKey(preferences) @@ -172,11 +172,6 @@ func (s *proposerPreferencesService) ProcessMessage(ctx context.Context, _ *uint return nil } -func (s *proposerPreferencesService) hasSeenPreference(key seenProposerPreferencesKey) bool { - _, ok := s.epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: key.slot, DependentRoot: key.dependentRoot}) - return ok -} - func isPastSlot(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, now time.Time, slot uint64, disparity time.Duration) (bool, bool) { slotTime, ok := safeSlotTime(clock, beaconCfg, slot) if !ok { @@ -185,6 +180,11 @@ func isPastSlot(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainCo return now.After(slotTime.Add(disparity)), true } +func (s *proposerPreferencesService) hasSeenPreference(key seenProposerPreferencesKey) bool { + _, ok := s.epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: key.slot, DependentRoot: key.dependentRoot}) + return ok +} + func isPastBidWindow(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, now time.Time, slot uint64) bool { if slot == ^uint64(0) { return true diff --git a/cl/phase1/network/services/proposer_preferences_service_test.go b/cl/phase1/network/services/proposer_preferences_service_test.go index b61386cebea..5ac9f586c86 100644 --- a/cl/phase1/network/services/proposer_preferences_service_test.go +++ b/cl/phase1/network/services/proposer_preferences_service_test.go @@ -234,23 +234,30 @@ func TestProposerPreferencesServiceCurrentSlotWithinDisparityAccepted(t *testing require.NoError(t, err) } -func TestIsPastSlotClockDisparityBoundaries(t *testing.T) { +func TestIsPastSlotBoundaries(t *testing.T) { ctrl := gomock.NewController(t) service, _, _, _, _ := setupProposerPreferencesService(t, ctrl) start := service.ethClock.GetSlotTime(100) - - past, valid := isPastSlot(service.ethClock, service.beaconCfg, start.Add(gloasMaximumClockDisparity-time.Millisecond), 100, gloasMaximumClockDisparity) - require.True(t, valid) - require.False(t, past) - past, valid = isPastSlot(service.ethClock, service.beaconCfg, start.Add(gloasMaximumClockDisparity), 100, gloasMaximumClockDisparity) - require.True(t, valid) - require.False(t, past) - past, valid = isPastSlot(service.ethClock, service.beaconCfg, start.Add(gloasMaximumClockDisparity+time.Millisecond), 100, gloasMaximumClockDisparity) - require.True(t, valid) - require.True(t, past) + tests := []struct { + name string + now time.Time + past bool + }{ + {"before_start", start.Add(-time.Nanosecond), false}, + {"at_start", start, false}, + {"at_disparity", start.Add(gloasMaximumClockDisparity), false}, + {"after_disparity", start.Add(gloasMaximumClockDisparity + time.Nanosecond), true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + past, valid := isPastSlot(service.ethClock, service.beaconCfg, test.now, 100, gloasMaximumClockDisparity) + require.True(t, valid) + require.Equal(t, test.past, past) + }) + } } -func TestProposerPreferencesServiceAcceptsEpochRolloverDisparityEdge(t *testing.T) { +func TestProposerPreferencesServiceAcceptsCurrentSlotAtDisparityEdge(t *testing.T) { ctrl := gomock.NewController(t) service, _, _, epbsPool, _ := setupProposerPreferencesService(t, ctrl) msg := newTestSignedProposerPreferences(96, 42) @@ -272,7 +279,7 @@ func TestProposerPreferencesServiceEmitsEvent(t *testing.T) { events := make(chan *beaconevents.EventStream, 1) subscription := emitter.Operation().Subscribe(events) defer subscription.Unsubscribe() - msg := newTestSignedProposerPreferences(96, 42) + msg := newTestSignedProposerPreferences(100, 42) service.now = func() time.Time { return service.ethClock.GetSlotTime(96).Add(gloasMaximumClockDisparity) } require.NoError(t, service.ProcessMessage(context.Background(), nil, msg)) @@ -299,7 +306,7 @@ func TestProposerPreferencesServiceProgressesWhileEventFeedIsBlocked(t *testing. }() <-ready - msg := newTestSignedProposerPreferences(96, 42) + msg := newTestSignedProposerPreferences(100, 42) service.now = func() time.Time { return service.ethClock.GetSlotTime(96).Add(gloasMaximumClockDisparity) } processDone := make(chan error, 1) ctx := t.Context() @@ -310,7 +317,7 @@ func TestProposerPreferencesServiceProgressesWhileEventFeedIsBlocked(t *testing. case <-time.After(5 * time.Second): t.Fatal("proposer preferences processing blocked on the event feed") } - stored, ok := epbsPool.GetPreference(96, testDependentRoot) + stored, ok := epbsPool.GetPreference(100, testDependentRoot) require.True(t, ok) require.Same(t, msg, stored) @@ -375,14 +382,14 @@ func TestProposerPreferencesServiceDuplicateRetainedBeyondFormerCapacity(t *test epbsPool.ProposerPreferences.Add(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: root}, newTestSignedProposerPreferences(100, 42)) } epbsPool.ProposerPreferences.Add(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: msg.Message.DependentRoot}, msg) - ethClock.EXPECT().GetCurrentSlot().Return(uint64(100)) + ethClock.EXPECT().GetCurrentSlot().Return(uint64(99)) err := service.ProcessMessage(context.Background(), nil, msg) require.ErrorIs(t, err, ErrIgnore) require.Contains(t, err.Error(), "already seen proposer preferences") } -func TestProposerPreferencesServicePrunesSeenMarkerAfterBoundary(t *testing.T) { +func TestProposerPreferencesServiceDoesNotPruneOnExpiredMessage(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() service, _, _, epbsPool, _ := setupProposerPreferencesService(t, ctrl) @@ -408,7 +415,7 @@ func TestProposerPreferencesServicePrunesSeenMarkerAfterBoundary(t *testing.T) { err = service.ProcessMessage(context.Background(), nil, msg) require.ErrorIs(t, err, ErrIgnore) _, found = epbsPool.ProposerPreferences.Get(key) - require.False(t, found) + require.True(t, found) } func TestProposerPreferencesServiceDependentRootStateMissing(t *testing.T) { @@ -667,6 +674,16 @@ func TestProposerPreferencesServiceDecodeGossipMessageInvalid(t *testing.T) { require.Error(t, err) } +func TestProposerPreferencesServiceDecodeRejectsTrailingByte(t *testing.T) { + ctrl := gomock.NewController(t) + service, _, _, _, _ := setupProposerPreferencesService(t, ctrl) + encoded, err := newTestSignedProposerPreferences(100, 42).EncodeSSZ(nil) + require.NoError(t, err) + + _, err = service.DecodeGossipMessage("peer123", append(encoded, 0), clparams.GloasVersion) + require.Error(t, err) +} + func TestProposerPreferencesServiceFailedValidationNotStored(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/cl/pool/epbs_pool.go b/cl/pool/epbs_pool.go index 2a99292e04f..c899077eddd 100644 --- a/cl/pool/epbs_pool.go +++ b/cl/pool/epbs_pool.go @@ -15,20 +15,36 @@ const ( type slotMap[K comparable, V any] struct { mu sync.RWMutex values map[K]V + bySlot map[uint64]map[K]struct{} slotFor func(K) uint64 } func newSlotMap[K comparable, V any](slotFor func(K) uint64) *slotMap[K, V] { - return &slotMap[K, V]{values: make(map[K]V), slotFor: slotFor} + return &slotMap[K, V]{values: make(map[K]V), bySlot: make(map[uint64]map[K]struct{}), slotFor: slotFor} } func (m *slotMap[K, V]) Add(key K, value V) bool { m.mu.Lock() defer m.mu.Unlock() m.values[key] = value + slot := m.slotFor(key) + if m.bySlot[slot] == nil { + m.bySlot[slot] = make(map[K]struct{}) + } + m.bySlot[slot][key] = struct{}{} return false } +func (m *slotMap[K, V]) ValuesForSlot(slot uint64) []V { + m.mu.RLock() + defer m.mu.RUnlock() + values := make([]V, 0, len(m.bySlot[slot])) + for key := range m.bySlot[slot] { + values = append(values, m.values[key]) + } + return values +} + func (m *slotMap[K, V]) Get(key K) (V, bool) { m.mu.RLock() defer m.mu.RUnlock() @@ -46,16 +62,15 @@ func (m *slotMap[K, V]) Keys() []K { return keys } -func (m *slotMap[K, V]) PruneSlotsBefore(slot uint64) { - m.PruneSlots(func(entrySlot uint64) bool { return entrySlot < slot }) -} - func (m *slotMap[K, V]) PruneSlots(remove func(uint64) bool) { m.mu.Lock() defer m.mu.Unlock() - for key := range m.values { - if remove(m.slotFor(key)) { - delete(m.values, key) + for slot, keys := range m.bySlot { + if remove(slot) { + for key := range keys { + delete(m.values, key) + } + delete(m.bySlot, slot) } } } @@ -116,12 +131,10 @@ func NewEpbsPool() *EpbsPool { // regardless of dependent_root. This is used by the bid service which needs to find any // valid preferences for a slot across different fork views. func (p *EpbsPool) GetPreferencesForSlot(slot uint64) []*cltypes.SignedProposerPreferences { - var results []*cltypes.SignedProposerPreferences - for _, key := range p.ProposerPreferences.Keys() { - if key.Slot != slot { - continue - } - if msg, ok := p.ProposerPreferences.Get(key); ok && msg != nil { + values := p.ProposerPreferences.ValuesForSlot(slot) + results := make([]*cltypes.SignedProposerPreferences, 0, len(values)) + for _, msg := range values { + if msg != nil { results = append(results, msg) } } diff --git a/cl/pool/operations_pool_test.go b/cl/pool/operations_pool_test.go index 87e6175c1da..edd927d01c5 100644 --- a/cl/pool/operations_pool_test.go +++ b/cl/pool/operations_pool_test.go @@ -437,8 +437,8 @@ func TestEpbsPoolPrunesEntriesBeforeSlot(t *testing.T) { p.HighestBids.Add(HighestBidKey{Slot: 99}, &cltypes.SignedExecutionPayloadBid{}) p.HighestBids.Add(HighestBidKey{Slot: 100}, &cltypes.SignedExecutionPayloadBid{}) - p.ProposerPreferences.PruneSlotsBefore(100) - p.HighestBids.PruneSlotsBefore(100) + p.ProposerPreferences.PruneSlots(func(slot uint64) bool { return slot < 100 }) + p.HighestBids.PruneSlots(func(slot uint64) bool { return slot < 100 }) _, oldPreferencesFound := p.ProposerPreferences.Get(ProposerPreferencesKey{Slot: 99}) _, livePreferencesFound := p.ProposerPreferences.Get(ProposerPreferencesKey{Slot: 100}) @@ -449,3 +449,20 @@ func TestEpbsPoolPrunesEntriesBeforeSlot(t *testing.T) { require.False(t, oldBidFound) require.True(t, liveBidFound) } + +func TestSlotMapPrunesSlotBucketsInsteadOfEntries(t *testing.T) { + const slot = uint64(100) + m := newSlotMap[ProposerPreferencesKey, int](func(key ProposerPreferencesKey) uint64 { return key.Slot }) + for i := range 2048 { + m.Add(ProposerPreferencesKey{Slot: slot, DependentRoot: common.Hash{byte(i), byte(i >> 8)}}, i) + } + require.Len(t, m.ValuesForSlot(slot), 2048) + + visited := 0 + m.PruneSlots(func(entrySlot uint64) bool { + visited++ + return entrySlot < slot + }) + require.Equal(t, 1, visited) + require.Len(t, m.ValuesForSlot(slot), 2048) +} From 7492cfaafabd5162a9c5ac81b708d03cf042a4f9 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 12:51:23 +0800 Subject: [PATCH 19/43] cl/beacon: align payload attestation data API --- cl/beacon/beaconhttp/api.go | 6 +- cl/beacon/beaconhttp/beacon_response.go | 9 +- cl/beacon/handler/epbs.go | 30 ++--- cl/beacon/handler/epbs_test.go | 160 +++++++++++++++++++++++- cl/beacon/handler/handler.go | 1 + 5 files changed, 186 insertions(+), 20 deletions(-) diff --git a/cl/beacon/beaconhttp/api.go b/cl/beacon/beaconhttp/api.go index 293904207a0..2b7c110ab09 100644 --- a/cl/beacon/beaconhttp/api.go +++ b/cl/beacon/beaconhttp/api.go @@ -150,6 +150,10 @@ func HandleEndpoint[T any](h EndpointHandler[T]) http.HandlerFunc { if beaconResponse.Version != nil && w.Header().Get("Eth-Consensus-Version") == "" { w.Header().Set("Eth-Consensus-Version", beaconResponse.Version.String()) } + if beaconResponse.noContent { + w.WriteHeader(http.StatusNoContent) + return + } } switch responseEncodingForAccept(contentType, supportsSSZ(ans)) { case responseEncodingJSON: @@ -186,7 +190,7 @@ func HandleEndpoint[T any](h EndpointHandler[T]) http.HandlerFunc { case responseEncodingEventStream: return default: - http.Error(w, "content type must include application/json, application/octet-stream, or text/event-stream, got "+contentType, http.StatusBadRequest) + http.Error(w, "content type must include application/json, application/octet-stream, or text/event-stream, got "+contentType, http.StatusNotAcceptable) } } } diff --git a/cl/beacon/beaconhttp/beacon_response.go b/cl/beacon/beaconhttp/beacon_response.go index c66869c9c24..1bb69968e99 100644 --- a/cl/beacon/beaconhttp/beacon_response.go +++ b/cl/beacon/beaconhttp/beacon_response.go @@ -31,8 +31,9 @@ type BeaconResponse struct { Version *clparams.StateVersion ExecutionOptimistic *bool - Extra map[string]any - headers map[string]string + Extra map[string]any + headers map[string]string + noContent bool } func NewBeaconResponse(data any) *BeaconResponse { @@ -41,6 +42,10 @@ func NewBeaconResponse(data any) *BeaconResponse { } } +func NewNoContentResponse() *BeaconResponse { + return &BeaconResponse{noContent: true} +} + func (r *BeaconResponse) Headers() map[string]string { if r.headers == nil { return make(map[string]string) diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index c60f4ee037b..f6031c55116 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -178,28 +178,29 @@ func (a *ApiHandler) PostEthV1ValidatorDutiesPtc(w http.ResponseWriter, r *http. // ---- Payload Attestation Data ---- -// payloadAttestationDataResponse matches the PayloadAttestationData spec type. -type payloadAttestationDataResponse struct { - BeaconBlockRoot common.Hash `json:"beacon_block_root"` - Slot uint64 `json:"slot,string"` - PayloadPresent bool `json:"payload_present"` - BlobDataAvailable bool `json:"blob_data_available"` -} - // GetEthV1ValidatorPayloadAttestationData returns PayloadAttestationData for PTC validators. -// GET /eth/v1/validator/payload_attestation_data/{slot} +// GET /eth/v1/validator/payload_attestation_data?slot={slot} // [New in Gloas:EIP7732] func (a *ApiHandler) GetEthV1ValidatorPayloadAttestationData(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { + if a.syncedData.Syncing() { + return nil, beaconhttp.NewEndpointError(http.StatusServiceUnavailable, errors.New("beacon node is syncing")) + } slotStr, err := beaconhttp.StringFromRequest(r, "slot") if err != nil { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) } + if slotStr == "" { + slotValues, ok := r.URL.Query()["slot"] + if !ok || len(slotValues) != 1 || slotValues[0] == "" { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("slot query parameter is required exactly once")) + } + slotStr = slotValues[0] + } slot, err := strconv.ParseUint(slotStr, 10, 64) if err != nil { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid slot: %w", err)) } - // Must be GLOAS epoch if a.beaconChainCfg.SlotsPerEpoch == 0 { return nil, beaconhttp.NewEndpointError(http.StatusServiceUnavailable, errors.New("slots per epoch is zero")) @@ -211,15 +212,14 @@ func (a *ApiHandler) GetEthV1ValidatorPayloadAttestationData(w http.ResponseWrit } // Get the beacon block root for this slot from fork choice - headRoot, headSlot, _, err := a.getSelectedHead() + headRoot, headSlot, statusCode, err := a.getSelectedHead() if err != nil { - return nil, err + return nil, beaconhttp.NewEndpointError(statusCode, err) } // The PTC attests to the current slot's block if slot != headSlot { - return nil, beaconhttp.NewEndpointError(http.StatusNotFound, - fmt.Errorf("payload attestation data only available for head slot %d, requested %d", headSlot, slot)) + return beaconhttp.NewNoContentResponse(), nil } // Check payload status: has the execution payload envelope been received? @@ -231,7 +231,7 @@ func (a *ApiHandler) GetEthV1ValidatorPayloadAttestationData(w http.ResponseWrit // (b) all local custody columns are present per PeerDAS. blobDataAvailable := a.forkchoiceStore.IsBlobDataAvailable(slot, headRoot) - return newBeaconResponse(payloadAttestationDataResponse{ + return newBeaconResponse(&cltypes.PayloadAttestationData{ BeaconBlockRoot: headRoot, Slot: slot, PayloadPresent: payloadPresent, diff --git a/cl/beacon/handler/epbs_test.go b/cl/beacon/handler/epbs_test.go index 066c6f15db6..faa25e72f37 100644 --- a/cl/beacon/handler/epbs_test.go +++ b/cl/beacon/handler/epbs_test.go @@ -33,6 +33,7 @@ import ( "go.uber.org/mock/gomock" "github.com/erigontech/erigon/cl/beacon/beaconevents" + sync_mock_services "github.com/erigontech/erigon/cl/beacon/synced_data/mock_services" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" @@ -47,6 +48,161 @@ import ( "github.com/erigontech/erigon/common/log/v3" ) +func TestGetPayloadAttestationDataAcceptsCanonicalSlotQuery(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 2 + fcu.HeadSlotVal = 64 + fcu.HeadVal = common.HexToHash("0x1234") + + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=64", http.NoBody) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, "gloas", recorder.Header().Get("Eth-Consensus-Version")) + require.Contains(t, recorder.Body.String(), `"slot":"64"`) +} + +func TestGetPayloadAttestationDataSupportsSSZ(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 2 + fcu.HeadSlotVal = 64 + fcu.HeadVal = common.HexToHash("0x1234") + + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=64", http.NoBody) + request.Header.Set("Accept", "application/octet-stream") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, "application/octet-stream", recorder.Header().Get("Content-Type")) + require.Equal(t, "gloas", recorder.Header().Get("Eth-Consensus-Version")) + data := new(cltypes.PayloadAttestationData) + require.NoError(t, data.DecodeSSZStrict(recorder.Body.Bytes(), int(clparams.GloasVersion))) + require.Equal(t, uint64(64), data.Slot) + require.Equal(t, fcu.HeadVal, data.BeaconBlockRoot) +} + +func TestGetPayloadAttestationDataRejectsPreGloasSlot(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 2 + fcu.HeadSlotVal = 63 + + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=63", http.NoBody) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) +} + +func TestGetPayloadAttestationDataRejectsUnsupportedResponseType(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 0 + fcu.HeadSlotVal = 64 + + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=64", http.NoBody) + request.Header.Set("Accept", "text/plain") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusNotAcceptable, recorder.Code, recorder.Body.String()) +} + +func TestGetPayloadAttestationDataReturnsNoContentWithoutSlotBlock(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 0 + fcu.HeadSlotVal = 63 + + for _, accept := range []string{"application/json", "application/octet-stream"} { + t.Run(accept, func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=64", http.NoBody) + request.Header.Set("Accept", accept) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusNoContent, recorder.Code, recorder.Body.String()) + require.Empty(t, recorder.Body.String()) + }) + } +} + +func TestGetPayloadAttestationDataReportsSyncing(t *testing.T) { + for _, path := range []string{ + "/eth/v1/validator/payload_attestation_data?slot=64", + "/eth/v1/validator/payload_attestation_data?slot=invalid", + "/eth/v1/validator/payload_attestation_data", + } { + t.Run(path, func(t *testing.T) { + _, _, _, _, _, handler, _, syncedData, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), false) + handler.beaconChainCfg.GloasForkEpoch = 0 + syncedData.(*sync_mock_services.MockSyncedData).EXPECT().Syncing().Return(true) + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, http.NoBody)) + + require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String()) + }) + } +} + +func TestGetPayloadAttestationDataPreservesUnavailableHeadStatus(t *testing.T) { + _, _, _, _, _, handler, _, syncedData, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), false) + handler.beaconChainCfg.GloasForkEpoch = 0 + handler.enableMemoizedHeadState = true + mockSyncedData := syncedData.(*sync_mock_services.MockSyncedData) + mockSyncedData.EXPECT().Syncing().Return(false) + mockSyncedData.EXPECT().StateHead().Return(common.Hash{}, uint64(0), false) + + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=64", http.NoBody) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String()) +} + +func TestGetPayloadAttestationDataRejectsInvalidCanonicalSlotQuery(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 0 + + for _, path := range []string{ + "/eth/v1/validator/payload_attestation_data", + "/eth/v1/validator/payload_attestation_data?slot=", + "/eth/v1/validator/payload_attestation_data?slot=64&slot=64", + "/eth/v1/validator/payload_attestation_data?slot=invalid", + "/eth/v1/validator/payload_attestation_data?slot=18446744073709551616", + } { + t.Run(path, func(t *testing.T) { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, http.NoBody)) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + }) + } +} + +func TestGetPayloadAttestationDataPreservesLegacyPathAlias(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 0 + fcu.HeadSlotVal = 64 + + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data/64", http.NoBody) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) +} + +func TestGetPayloadAttestationDataAcceptsMaximumSlot(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 0 + fcu.HeadSlotVal = ^uint64(0) + + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=18446744073709551615", http.NoBody) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) +} + func TestPostPayloadAttestationsRejectsNullMessage(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) @@ -680,8 +836,8 @@ func TestGetValidatorExecutionPayloadBidReturnsUnsignedBid(t *testing.T) { require.NotContains(t, recorder.Body.String(), `"message"`) for _, path := range []string{ - fmt.Sprintf("/eth/v1/validator/execution_payload_bid/%d/3", slot-1), - fmt.Sprintf("/eth/v1/validator/execution_payload_bid/%d/3", slot+2), + "/eth/v1/validator/execution_payload_bid/0/3", + "/eth/v1/validator/execution_payload_bid/18446744073709551615/3", fmt.Sprintf("/eth/v1/validator/execution_payload_bid/%d/4", slot), } { recorder = httptest.NewRecorder() diff --git a/cl/beacon/handler/handler.go b/cl/beacon/handler/handler.go index 0eac1665d5a..3cbda8ba98c 100644 --- a/cl/beacon/handler/handler.go +++ b/cl/beacon/handler/handler.go @@ -432,6 +432,7 @@ func (a *ApiHandler) init() { r.Post("/prepare_beacon_proposer", a.PostEthV1ValidatorPrepareBeaconProposal) r.Post("/liveness/{epoch}", beaconhttp.HandleEndpointFunc(a.liveness)) // [New in Gloas:EIP7732] + r.Get("/payload_attestation_data", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorPayloadAttestationData)) r.Get("/payload_attestation_data/{slot}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorPayloadAttestationData)) r.Post("/proposer_preferences", a.PostEthV1ValidatorProposerPreferences) r.Post("/builder_preferences", a.PostEthV1ValidatorBuilderPreferences) From 21647724b3ef8ceb8fedf46163026ff7115c51d4 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 15:13:20 +0800 Subject: [PATCH 20/43] cl: tighten Gloas lifecycle and payload validation --- cl/beacon/handler/block_production.go | 30 ++- cl/beacon/handler/block_production_test.go | 35 +++ cl/cltypes/execution_requests.go | 10 +- cl/cltypes/solid/list_ssz.go | 4 + cl/cltypes/solid/list_ssz_test.go | 4 + .../forkchoice/fork_graph/fork_graph_disk.go | 53 +++-- .../forkchoice/fork_graph/fork_graph_test.go | 202 ++++++++++++++++++ cl/phase1/forkchoice/payload_vote.go | 4 +- cl/phase1/forkchoice/payload_vote_test.go | 42 ++-- cl/phase1/network/services/block_service.go | 1 - cl/phase1/network/services/constants.go | 1 - .../services/execution_payload_bid_service.go | 85 -------- .../execution_payload_bid_service_test.go | 116 +--------- .../execution_payload_service_test.go | 28 ++- 14 files changed, 366 insertions(+), 249 deletions(-) diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index e5b2a254279..c08fde1d569 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -1082,6 +1082,7 @@ func (a *ApiHandler) requestConfiguredBuilderBids( } parentGasLimit, ok := a.forkchoiceStore.GetExecutionPayloadGasLimit(parentBid.ParentBlockHash) if !ok { + a.logger.Debug("Skipping configured builder bids because parent gas limit is unavailable", "parentBlockHash", parentBid.ParentBlockHash) return nil } proposerIndex, err := baseState.GetBeaconProposerIndexForSlot(targetSlot) @@ -1245,22 +1246,31 @@ func (a *ApiHandler) getBuilderPayload( } } if baseState.Version() >= clparams.ElectraVersion && header.Data.Message.ExecutionRequests != nil { - // check execution requests - r := header.Data.Message.ExecutionRequests - if r.Deposits != nil && r.Deposits.Len() > int(a.beaconChainCfg.MaxDepositRequestsPerPayload) { - return nil, fmt.Errorf("too many deposit requests: %d", r.Deposits.Len()) - } - if r.Withdrawals != nil && r.Withdrawals.Len() > int(a.beaconChainCfg.MaxWithdrawalRequestsPerPayload) { - return nil, fmt.Errorf("too many withdrawal requests: %d", r.Withdrawals.Len()) - } - if r.Consolidations != nil && r.Consolidations.Len() > int(a.beaconChainCfg.MaxConsolidationRequestsPerPayload) { - return nil, fmt.Errorf("too many consolidation requests: %d", r.Consolidations.Len()) + if err := validateBuilderExecutionRequests(a.beaconChainCfg, baseState.Version(), header.Data.Message.ExecutionRequests); err != nil { + return nil, err } } return header, nil } +func validateBuilderExecutionRequests(cfg *clparams.BeaconChainConfig, version clparams.StateVersion, requests *cltypes.ExecutionRequests) error { + depositLimit := int(cfg.MaxDepositRequestsPerPayload) + if version >= clparams.GloasVersion { + depositLimit = int(clparams.MaxChunkSize) / solid.SizeDepositRequest + } + if requests.Deposits != nil && requests.Deposits.Len() > depositLimit { + return fmt.Errorf("too many deposit requests: %d", requests.Deposits.Len()) + } + if requests.Withdrawals != nil && requests.Withdrawals.Len() > int(cfg.MaxWithdrawalRequestsPerPayload) { + return fmt.Errorf("too many withdrawal requests: %d", requests.Withdrawals.Len()) + } + if requests.Consolidations != nil && requests.Consolidations.Len() > int(cfg.MaxConsolidationRequestsPerPayload) { + return fmt.Errorf("too many consolidation requests: %d", requests.Consolidations.Len()) + } + return nil +} + func (a *ApiHandler) produceBeaconBody( ctx context.Context, apiVersion int, diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index a955f7de192..66a598e6d8a 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -1135,6 +1135,41 @@ func TestValidateBuilderPayloadRejectsMissingElectraExecutionRequests(t *testing require.NoError(t, validateBuilderPayload(payload, cltypes.NewExecutionRequestsWithVersion(&clparams.MainnetBeaconConfig, clparams.ElectraVersion), clparams.ElectraVersion)) } +func TestValidateBuilderExecutionRequestsDoesNotApplyLegacyGloasDepositMaximum(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + cfg.MaxDepositRequestsPerPayload = 1 + requests := cltypes.NewExecutionRequestsWithVersion(&cfg, clparams.GloasVersion) + requests.Deposits.Append(&solid.DepositRequest{}) + requests.Deposits.Append(&solid.DepositRequest{}) + + require.NoError(t, validateBuilderExecutionRequests(&cfg, clparams.GloasVersion, requests)) +} + +func TestValidateBuilderExecutionRequestsBoundsGloasDepositResources(t *testing.T) { + requests := cltypes.NewExecutionRequestsWithVersion(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + request := &solid.DepositRequest{} + for range int(clparams.MaxChunkSize)/solid.SizeDepositRequest + 1 { + requests.Deposits.Append(request) + } + + require.ErrorContains(t, validateBuilderExecutionRequests(&clparams.MainnetBeaconConfig, clparams.GloasVersion, requests), "too many deposit requests") +} + +func TestValidateBuilderExecutionRequestsPreservesOtherProtocolLimits(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + cfg.MaxDepositRequestsPerPayload = 1 + cfg.MaxWithdrawalRequestsPerPayload = 1 + electra := cltypes.NewExecutionRequestsWithVersion(&cfg, clparams.ElectraVersion) + electra.Deposits.Append(&solid.DepositRequest{}) + electra.Deposits.Append(&solid.DepositRequest{}) + require.ErrorContains(t, validateBuilderExecutionRequests(&cfg, clparams.ElectraVersion, electra), "too many deposit requests") + + gloas := cltypes.NewExecutionRequestsWithVersion(&cfg, clparams.GloasVersion) + gloas.Withdrawals.Append(&solid.WithdrawalRequest{}) + gloas.Withdrawals.Append(&solid.WithdrawalRequest{}) + require.ErrorContains(t, validateBuilderExecutionRequests(&cfg, clparams.GloasVersion, gloas), "too many withdrawal requests") +} + func TestBlockBuilderWindowLateStartKeepsPublicationMargin(t *testing.T) { cfg := &clparams.BeaconChainConfig{ SecondsPerSlot: 12, diff --git a/cl/cltypes/execution_requests.go b/cl/cltypes/execution_requests.go index 88fc7f1279a..04ca3159fae 100644 --- a/cl/cltypes/execution_requests.go +++ b/cl/cltypes/execution_requests.go @@ -19,6 +19,8 @@ var ( _ ssz2.SizedObjectSSZ = (*ExecutionRequests)(nil) ) +const gloasDepositRequestsResourceLimit = int(clparams.MaxChunkSize) / solid.SizeDepositRequest + // ExecutionRequests groups execution-layer requests carried by a payload. // Electra defines deposits, withdrawals, and consolidations; Gloas adds builder // deposits and exits. @@ -59,7 +61,7 @@ func (e *ExecutionRequests) ensureLists() { } progressive := e.effectiveVersion() >= clparams.GloasVersion if e.Deposits == nil && progressive { - e.Deposits = solid.NewStaticProgressiveListSSZ[*solid.DepositRequest](int(e.cfg.MaxDepositRequestsPerPayload), solid.SizeDepositRequest) + e.Deposits = newGloasDepositRequests() } else if e.Deposits == nil { e.Deposits = solid.NewStaticListSSZ[*solid.DepositRequest](int(e.cfg.MaxDepositRequestsPerPayload), solid.SizeDepositRequest) } @@ -85,6 +87,10 @@ func (e *ExecutionRequests) ensureLists() { } } +func newGloasDepositRequests() *solid.ListSSZ[*solid.DepositRequest] { + return solid.NewStaticProgressiveListSSZWithResourceLimit[*solid.DepositRequest](gloasDepositRequestsResourceLimit, solid.SizeDepositRequest) +} + func (e *ExecutionRequests) EncodingSizeSSZ() int { e.ensureLists() // Every field is a dynamic list, so each contributes a 4-byte offset. @@ -225,7 +231,7 @@ func (e *ExecutionRequests) UnmarshalJSON(b []byte) error { newBuilderDeposits := solid.NewStaticListSSZ[*solid.BuilderDepositRequest](int(e.cfg.MaxBuilderDepositRequestsPerPayload), solid.SizeBuilderDepositRequest) newBuilderExits := solid.NewStaticListSSZ[*solid.BuilderExitRequest](int(e.cfg.MaxBuilderExitRequestsPerPayload), solid.SizeBuilderExitRequest) if e.effectiveVersion() >= clparams.GloasVersion { - newDeposits = solid.NewStaticProgressiveListSSZ[*solid.DepositRequest](int(e.cfg.MaxDepositRequestsPerPayload), solid.SizeDepositRequest) + newDeposits = newGloasDepositRequests() newWithdrawals = solid.NewStaticProgressiveListSSZ[*solid.WithdrawalRequest](int(e.cfg.MaxWithdrawalRequestsPerPayload), solid.SizeWithdrawalRequest) newConsolidations = solid.NewStaticProgressiveListSSZ[*solid.ConsolidationRequest](int(e.cfg.MaxConsolidationRequestsPerPayload), solid.SizeConsolidationRequest) newBuilderDeposits = solid.NewStaticProgressiveListSSZ[*solid.BuilderDepositRequest](int(e.cfg.MaxBuilderDepositRequestsPerPayload), solid.SizeBuilderDepositRequest) diff --git a/cl/cltypes/solid/list_ssz.go b/cl/cltypes/solid/list_ssz.go index f8f68985136..052517d2c0b 100644 --- a/cl/cltypes/solid/list_ssz.go +++ b/cl/cltypes/solid/list_ssz.go @@ -68,6 +68,10 @@ func NewStaticProgressiveListSSZ[T EncodableHashableSSZ](limit int, bytesPerElem return &ListSSZ[T]{list: make([]T, 0), limit: progressiveDecodeLimit(limit), static: true, bytesPerElement: bytesPerElement, progressive: true} } +func NewStaticProgressiveListSSZWithResourceLimit[T EncodableHashableSSZ](limit int, bytesPerElement int) *ListSSZ[T] { + return &ListSSZ[T]{list: make([]T, 0), limit: limit, static: true, bytesPerElement: bytesPerElement, progressive: true} +} + func (l *ListSSZ[T]) EnsureStaticProgressive(limit int, bytesPerElement int) { if l.progressive && l.static && l.bytesPerElement == bytesPerElement { return diff --git a/cl/cltypes/solid/list_ssz_test.go b/cl/cltypes/solid/list_ssz_test.go index 9b2c70c88e8..628759256f5 100644 --- a/cl/cltypes/solid/list_ssz_test.go +++ b/cl/cltypes/solid/list_ssz_test.go @@ -142,6 +142,10 @@ func TestProgressiveListSSZDecodeEnforcesLimit(t *testing.T) { name: "static", list: NewStaticProgressiveListSSZ[Validator](1, validatorSize), }, + { + name: "static resource limit", + list: NewStaticProgressiveListSSZWithResourceLimit[Validator](1, validatorSize), + }, { name: "dynamic", list: NewDynamicProgressiveListSSZ[Validator](1), diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go b/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go index 7938262f2c8..98709517ad4 100644 --- a/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go +++ b/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go @@ -96,7 +96,9 @@ func convertHashSliceToHashList(in [][32]byte) solid.HashVectorSSZ { // each edge is the path described as (prevBlockRoot, currBlockRoot). if we want to go forward we use blocks. type forkGraphDisk struct { lifecycleMu sync.RWMutex + addPruneMu sync.Mutex pruneMu sync.Mutex + pruneBoundaryHook func() pruneBatchHook func() pruneChildrenHook func() @@ -230,8 +232,8 @@ func (f *forkGraphDisk) isBlockRootTheCurrentState(blockRoot common.Hash) bool { // Add a new node and edge to the graph func (f *forkGraphDisk) AddChainSegment(signedBlock *cltypes.SignedBeaconBlock, fullValidation bool) (*state.CachingBeaconState, ChainSegmentInsertionResult, error) { - f.lifecycleMu.Lock() - defer f.lifecycleMu.Unlock() + f.addPruneMu.Lock() + defer f.addPruneMu.Unlock() block := signedBlock.Block blockRoot, err := block.HashSSZ() @@ -393,18 +395,22 @@ func (f *forkGraphDisk) AddChainSegment(signedBlock *cltypes.SignedBeaconBlock, return nil, LogisticError, err } - f.headers.Store(common.Hash(blockRoot), &cltypes.BeaconBlockHeader{ + header := &cltypes.BeaconBlockHeader{ Slot: block.Slot, ProposerIndex: block.ProposerIndex, ParentRoot: block.ParentRoot, Root: block.StateRoot, BodyRoot: bodyRoot, - }) - f.addValidatedChild(block.ParentRoot, common.Hash(blockRoot), block.Slot) + } + currentJustified := newState.CurrentJustifiedCheckpoint() + finalized := newState.FinalizedCheckpoint() - // Lastly add checkpoints to caches as well. - f.currentJustifiedCheckpoints.Store(common.Hash(blockRoot), newState.CurrentJustifiedCheckpoint()) - f.finalizedCheckpoints.Store(common.Hash(blockRoot), newState.FinalizedCheckpoint()) + f.lifecycleMu.Lock() + f.headers.Store(common.Hash(blockRoot), header) + f.addValidatedChild(block.ParentRoot, common.Hash(blockRoot), block.Slot) + f.currentJustifiedCheckpoints.Store(common.Hash(blockRoot), currentJustified) + f.finalizedCheckpoints.Store(common.Hash(blockRoot), finalized) + f.lifecycleMu.Unlock() return newState, Success, nil } @@ -413,6 +419,14 @@ func isBelowPrunedBoundary(slot, lowestAvailable uint64) bool { return lowestAvailable > 0 && slot < lowestAvailable-1 } +func lastFullyPrunedEpoch(pruneSlot, slotsPerEpoch uint64) (uint64, bool) { + completedEpochs := pruneSlot / slotsPerEpoch + if completedEpochs == 0 { + return 0, false + } + return completedEpochs - 1, true +} + func (f *forkGraphDisk) GetHeader(blockRoot common.Hash) (*cltypes.BeaconBlockHeader, bool) { obj, has := f.headers.Load(blockRoot) if !has { @@ -743,6 +757,7 @@ func (f *forkGraphDisk) Prune(pruneSlot uint64) (err error) { return } + f.addPruneMu.Lock() f.lifecycleMu.Lock() // Prune runs without the fork choice lock, so concurrent (or stale queued) // calls may arrive out of order: only ever raise the marker. @@ -753,6 +768,16 @@ func (f *forkGraphDisk) Prune(pruneSlot uint64) (err error) { } } f.lifecycleMu.Unlock() + if lastPrunedEpoch, ok := lastFullyPrunedEpoch(pruneSlot, f.beaconCfg.SlotsPerEpoch); ok { + currentIndexKeys := f.currentIndicies.keysThrough(lastPrunedEpoch) + previousIndexKeys := f.previousIndicies.keysThrough(lastPrunedEpoch) + f.currentIndicies.deleteKeys(currentIndexKeys) + f.previousIndicies.deleteKeys(previousIndexKeys) + } + f.addPruneMu.Unlock() + if f.pruneBoundaryHook != nil { + f.pruneBoundaryHook() + } oldRoots := make([]common.Hash, 0, f.beaconCfg.SlotsPerEpoch) validatedRootsByParent := make(map[common.Hash][]common.Hash) @@ -767,8 +792,6 @@ func (f *forkGraphDisk) Prune(pruneSlot uint64) (err error) { } return true }) - currentIndexKeys := f.currentIndicies.keysThrough(pruneSlot / f.beaconCfg.SlotsPerEpoch) - previousIndexKeys := f.previousIndicies.keysThrough(pruneSlot / f.beaconCfg.SlotsPerEpoch) for start := 0; start < len(oldRoots); start += pruneBatchSize { end := min(start+pruneBatchSize, len(oldRoots)) f.lifecycleMu.Lock() @@ -790,16 +813,6 @@ func (f *forkGraphDisk) Prune(pruneSlot uint64) (err error) { f.pruneBatchHook() } } - for start := 0; start < max(len(currentIndexKeys), len(previousIndexKeys)); start += pruneBatchSize { - f.lifecycleMu.Lock() - if start < len(currentIndexKeys) { - f.currentIndicies.deleteKeys(currentIndexKeys[start:min(start+pruneBatchSize, len(currentIndexKeys))]) - } - if start < len(previousIndexKeys) { - f.previousIndicies.deleteKeys(previousIndexKeys[start:min(start+pruneBatchSize, len(previousIndexKeys))]) - } - f.lifecycleMu.Unlock() - } if f.pruneChildrenHook != nil { f.pruneChildrenHook() } diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_test.go b/cl/phase1/forkchoice/fork_graph/fork_graph_test.go index 53156064f9b..1b9d813c22e 100644 --- a/cl/phase1/forkchoice/fork_graph/fork_graph_test.go +++ b/cl/phase1/forkchoice/fork_graph/fork_graph_test.go @@ -20,6 +20,7 @@ import ( _ "embed" "errors" "os" + "runtime" "sync" "testing" "time" @@ -30,6 +31,7 @@ import ( "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" + "github.com/erigontech/erigon/cl/cltypes/solid" "github.com/erigontech/erigon/cl/utils" "github.com/erigontech/erigon/common" "github.com/stretchr/testify/require" @@ -337,6 +339,142 @@ func TestPruneKeepsLowestAvailableBlockMonotonic(t *testing.T) { require.Equal(t, uint64(151), f.LowestAvailableSlot()) } +func TestPruneKeepsParticipationIndicesFromRetainedConcurrentAdd(t *testing.T) { + const blockSlot = uint64(65) + const pruneSlot = blockSlot - 1 + cfg := &clparams.MainnetBeaconConfig + beaconState := state.New(cfg) + beaconState.SetVersion(clparams.AltairVersion) + require.NoError(t, beaconState.SetSlot(blockSlot)) + beaconState.SetLatestBlockHeader(&cltypes.BeaconBlockHeader{Slot: blockSlot}) + require.NoError(t, beaconState.SetCurrentSyncCommittee(solid.NewSyncCommittee())) + require.NoError(t, beaconState.SetNextSyncCommittee(solid.NewSyncCommittee())) + current := solid.ParticipationBitListFromBytes([]byte{1, 2}, int(cfg.ValidatorRegistryLimit)) + previous := solid.ParticipationBitListFromBytes([]byte{3, 4}, int(cfg.ValidatorRegistryLimit)) + beaconState.SetCurrentEpochParticipation(current) + beaconState.SetPreviousEpochParticipation(previous) + + block := cltypes.NewSignedBeaconBlock(cfg, clparams.AltairVersion) + block.Block.Slot = blockSlot + blockRoot, err := block.Block.HashSSZ() + require.NoError(t, err) + beaconState.SetPreviousStateRoot(common.Hash(blockRoot)) + boundaryPublished := make(chan struct{}) + continuePrune := make(chan struct{}) + f := &forkGraphDisk{ + fs: afero.NewMemMapFs(), + beaconCfg: cfg, + rcfg: beacon_router_configuration.RouterConfiguration{Beacon: true}, + children: make(map[common.Hash]*validatedChildren), + currentState: beaconState, + currentStateBlockRoot: common.Hash(blockRoot), + pruneBoundaryHook: func() { + close(boundaryPublished) + <-continuePrune + }, + } + newerRoot := common.Hash{0xff} + newerBlock := cltypes.NewSignedBeaconBlock(cfg, clparams.AltairVersion) + newerBlock.Block.Slot = blockSlot + cfg.SlotsPerEpoch + f.blocks.Store(newerRoot, newerBlock) + require.NoError(t, afero.WriteFile(f.fs, getBeaconStateFilename(newerRoot), []byte{1}, 0o644)) + + pruneDone := make(chan error, 1) + go func() { pruneDone <- f.Prune(pruneSlot) }() + select { + case <-boundaryPublished: + case <-time.After(time.Second): + t.Fatal("prune did not publish its boundary") + } + require.Equal(t, blockSlot, f.LowestAvailableSlot()) + _, result, err := f.AddChainSegment(block, false) + require.NoError(t, err) + require.Equal(t, Success, result) + close(continuePrune) + require.NoError(t, <-pruneDone) + + epoch := blockSlot / cfg.SlotsPerEpoch + gotCurrent, err := f.GetCurrentParticipationIndicies(epoch) + require.NoError(t, err) + require.NotNil(t, gotCurrent) + require.Equal(t, current.Bytes(), gotCurrent.Bytes()) + gotPrevious, err := f.GetPreviousParticipationIndicies(epoch) + require.NoError(t, err) + require.NotNil(t, gotPrevious) + require.Equal(t, previous.Bytes(), gotPrevious.Bytes()) +} + +func TestPruneKeepsParticipationIndicesFromRetainedPriorAdd(t *testing.T) { + const blockSlot = uint64(65) + const pruneSlot = uint64(64) + cfg := &clparams.MainnetBeaconConfig + beaconState := state.New(cfg) + beaconState.SetVersion(clparams.AltairVersion) + require.NoError(t, beaconState.SetSlot(blockSlot)) + beaconState.SetLatestBlockHeader(&cltypes.BeaconBlockHeader{Slot: blockSlot}) + require.NoError(t, beaconState.SetCurrentSyncCommittee(solid.NewSyncCommittee())) + require.NoError(t, beaconState.SetNextSyncCommittee(solid.NewSyncCommittee())) + current := solid.ParticipationBitListFromBytes([]byte{5, 6}, int(cfg.ValidatorRegistryLimit)) + previous := solid.ParticipationBitListFromBytes([]byte{7, 8}, int(cfg.ValidatorRegistryLimit)) + beaconState.SetCurrentEpochParticipation(current) + beaconState.SetPreviousEpochParticipation(previous) + + block := cltypes.NewSignedBeaconBlock(cfg, clparams.AltairVersion) + block.Block.Slot = blockSlot + blockRoot, err := block.Block.HashSSZ() + require.NoError(t, err) + beaconState.SetPreviousStateRoot(common.Hash(blockRoot)) + f := &forkGraphDisk{ + fs: afero.NewMemMapFs(), + beaconCfg: cfg, + rcfg: beacon_router_configuration.RouterConfiguration{Beacon: true}, + children: make(map[common.Hash]*validatedChildren), + currentState: beaconState, + currentStateBlockRoot: common.Hash(blockRoot), + } + _, result, err := f.AddChainSegment(block, false) + require.NoError(t, err) + require.Equal(t, Success, result) + newerRoot := common.Hash{0xfe} + newerBlock := cltypes.NewSignedBeaconBlock(cfg, clparams.AltairVersion) + newerBlock.Block.Slot = blockSlot + cfg.SlotsPerEpoch + f.blocks.Store(newerRoot, newerBlock) + require.NoError(t, afero.WriteFile(f.fs, getBeaconStateFilename(newerRoot), []byte{1}, 0o644)) + + require.NoError(t, f.Prune(pruneSlot)) + require.Equal(t, blockSlot, f.LowestAvailableSlot()) + _, retained := f.GetHeader(common.Hash(blockRoot)) + require.True(t, retained) + epoch := blockSlot / cfg.SlotsPerEpoch + gotCurrent, err := f.GetCurrentParticipationIndicies(epoch) + require.NoError(t, err) + require.NotNil(t, gotCurrent) + require.Equal(t, current.Bytes(), gotCurrent.Bytes()) + gotPrevious, err := f.GetPreviousParticipationIndicies(epoch) + require.NoError(t, err) + require.NotNil(t, gotPrevious) + require.Equal(t, previous.Bytes(), gotPrevious.Bytes()) +} + +func TestLastFullyPrunedEpoch(t *testing.T) { + for _, tc := range []struct { + pruneSlot uint64 + epoch uint64 + ok bool + }{ + {pruneSlot: 0}, + {pruneSlot: 31}, + {pruneSlot: 32, epoch: 0, ok: true}, + {pruneSlot: 63, epoch: 0, ok: true}, + {pruneSlot: 64, epoch: 1, ok: true}, + {pruneSlot: 65, epoch: 1, ok: true}, + } { + epoch, ok := lastFullyPrunedEpoch(tc.pruneSlot, 32) + require.Equal(t, tc.ok, ok, "prune slot %d", tc.pruneSlot) + require.Equal(t, tc.epoch, epoch, "prune slot %d", tc.pruneSlot) + } +} + func TestOrphanEnvelopeIsNotRediscoveredAfterRootRemoval(t *testing.T) { fs := afero.NewMemMapFs() f := &forkGraphDisk{fs: fs, beaconCfg: &clparams.MainnetBeaconConfig} @@ -694,3 +832,67 @@ func TestAddChainSegmentRejectsSlotBelowPrunedBoundary(t *testing.T) { require.False(t, isBelowPrunedBoundary(64, 65)) require.False(t, isBelowPrunedBoundary(^uint64(0), ^uint64(0))) } + +func TestAddChainSegmentDoesNotExcludeLifecycleReaders(t *testing.T) { + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) + anchorState := state.New(&clparams.MainnetBeaconConfig) + require.NoError(t, utils.DecodeSSZSnappy(block, block1, int(clparams.Phase0Version))) + require.NoError(t, utils.DecodeSSZSnappy(anchorState, anchor, int(clparams.Phase0Version))) + graph, err := NewForkGraphDisk(anchorState, nil, afero.NewMemMapFs(), beacon_router_configuration.RouterConfiguration{}) + require.NoError(t, err) + f := graph.(*forkGraphDisk) + blockRoot, err := block.Block.HashSSZ() + require.NoError(t, err) + + f.lifecycleMu.RLock() + lifecycleReaderHeld := true + t.Cleanup(func() { + if lifecycleReaderHeld { + f.lifecycleMu.RUnlock() + } + }) + + done := make(chan struct { + result ChainSegmentInsertionResult + err error + }, 1) + go func() { + _, result, err := f.AddChainSegment(block, true) + done <- struct { + result ChainSegmentInsertionResult + err error + }{result: result, err: err} + }() + + statePublished := make(chan struct{}) + go func() { + for { + f.currentStateMu.RLock() + published := f.currentStateBlockRoot == common.Hash(blockRoot) + f.currentStateMu.RUnlock() + if published { + close(statePublished) + return + } + runtime.Gosched() + } + }() + select { + case <-statePublished: + case <-time.After(time.Second): + t.Fatal("block insertion did not reach final lifecycle publication") + } + select { + case result := <-done: + t.Fatalf("block insertion completed before lifecycle reader released: %v", result) + default: + } + + f.lifecycleMu.RUnlock() + lifecycleReaderHeld = false + result := <-done + require.NoError(t, result.err) + require.Equal(t, Success, result.result) + _, headerFound := f.GetHeader(common.Hash(blockRoot)) + require.True(t, headerFound) +} diff --git a/cl/phase1/forkchoice/payload_vote.go b/cl/phase1/forkchoice/payload_vote.go index 0e5784d05d6..112cb2bf43d 100644 --- a/cl/phase1/forkchoice/payload_vote.go +++ b/cl/phase1/forkchoice/payload_vote.go @@ -412,9 +412,9 @@ func (f *ForkChoiceStore) isPayloadAvailable(root common.Hash) bool { } status, ok := f.GetRecentExecutionPayloadStatusByRoot(root) if !ok { - return f.HasEnvelope(root) + return false } - return status == execution_client.PayloadStatusNotValidated || status == execution_client.PayloadStatusValidated + return status == execution_client.PayloadStatusValidated } // validateParentPayloadPath validates that the block builds on the correct parent payload path. diff --git a/cl/phase1/forkchoice/payload_vote_test.go b/cl/phase1/forkchoice/payload_vote_test.go index b0d0ef7f3fc..f84a075c130 100644 --- a/cl/phase1/forkchoice/payload_vote_test.go +++ b/cl/phase1/forkchoice/payload_vote_test.go @@ -20,8 +20,9 @@ import ( type ptcVoteForkGraph struct { fork_graph.ForkGraph - envelopes map[common.Hash]bool - blocks map[common.Hash]*cltypes.SignedBeaconBlock + envelopes map[common.Hash]bool + blocks map[common.Hash]*cltypes.SignedBeaconBlock + acceptedPayloads map[common.Hash]bool } func (g ptcVoteForkGraph) HasEnvelope(root common.Hash) bool { @@ -39,8 +40,9 @@ func (g ptcVoteForkGraph) IsPayloadUnavailable(common.Hash) bool { } func (g ptcVoteForkGraph) MarkPayloadAccepted(common.Hash, bool) {} func (g ptcVoteForkGraph) ClearPayloadAccepted(common.Hash) {} -func (g ptcVoteForkGraph) PayloadAccepted(common.Hash) (bool, bool) { - return false, false +func (g ptcVoteForkGraph) PayloadAccepted(root common.Hash) (bool, bool) { + verified, ok := g.acceptedPayloads[root] + return verified, ok } func (g ptcVoteForkGraph) GetBlock(root common.Hash) (*cltypes.SignedBeaconBlock, bool) { @@ -394,13 +396,20 @@ func TestGloasForkChoiceUsesPersistedPayload(t *testing.T) { name string hasEnvelope bool verified bool + optimistic bool wantFullChild bool }{ { - name: "envelope present while EL syncs produces FULL child", + name: "envelope present without EL status remains EMPTY only", hasEnvelope: true, verified: false, - wantFullChild: true, + wantFullChild: false, + }, + { + name: "optimistic EL status remains EMPTY only", + hasEnvelope: true, + optimistic: true, + wantFullChild: false, }, { name: "envelope present and verified produces FULL child", @@ -419,6 +428,9 @@ func TestGloasForkChoiceUsesPersistedPayload(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f := newPayloadVoteTestStore(t, root, tt.hasEnvelope, tt.verified) + if tt.optimistic { + f.payloadStatusByRoot.Add(root, execution_client.PayloadStatusNotValidated) + } children := f.getNodeChildren(ForkChoiceNode{ Root: root, @@ -549,7 +561,7 @@ func TestPayloadAvailabilityByEngineStatus(t *testing.T) { want bool }{ {name: "engine error", status: execution_client.PayloadStatusNone}, - {name: "optimistic", status: execution_client.PayloadStatusNotValidated, want: true}, + {name: "optimistic", status: execution_client.PayloadStatusNotValidated}, } { t.Run(test.name, func(t *testing.T) { f := newPayloadVoteTestStore(t, root, true, false) @@ -578,8 +590,8 @@ func TestValidateParentPayloadPathUsesValidationAvailability(t *testing.T) { wantErr bool }{ {name: "engine error", status: execution_client.PayloadStatusNone, withStatus: true, wantErr: true}, - {name: "optimistic", status: execution_client.PayloadStatusNotValidated, withStatus: true}, - {name: "status absent"}, + {name: "optimistic", status: execution_client.PayloadStatusNotValidated, withStatus: true, wantErr: true}, + {name: "status absent", wantErr: true}, } { t.Run(test.name, func(t *testing.T) { f := newPayloadVoteTestStore(t, parentRoot, true, false) @@ -610,7 +622,7 @@ func TestApplyPayloadValidationResultRecordsRootAvailability(t *testing.T) { available bool }{ {name: "engine error", status: execution_client.PayloadStatusNone, wantErr: errELBehind}, - {name: "optimistic", status: execution_client.PayloadStatusNotValidated, available: true}, + {name: "optimistic", status: execution_client.PayloadStatusNotValidated}, } { t.Run(test.name, func(t *testing.T) { f := newPayloadVoteTestStore(t, root, true, false) @@ -667,10 +679,10 @@ func TestPayloadStatusTransitionsUpdateDurableAvailability(t *testing.T) { changed bool effective execution_client.PayloadStatus }{ - {name: "none to optimistic", initial: execution_client.PayloadStatusNone, next: execution_client.PayloadStatusNotValidated, available: true, changed: true, effective: execution_client.PayloadStatusNotValidated}, + {name: "none to optimistic", initial: execution_client.PayloadStatusNone, next: execution_client.PayloadStatusNotValidated, changed: true, effective: execution_client.PayloadStatusNotValidated}, {name: "none to validated", initial: execution_client.PayloadStatusNone, next: execution_client.PayloadStatusValidated, available: true, verified: true, changed: true, effective: execution_client.PayloadStatusValidated}, {name: "none to invalidated", initial: execution_client.PayloadStatusNone, next: execution_client.PayloadStatusInvalidated, changed: true, effective: execution_client.PayloadStatusInvalidated}, - {name: "optimistic to none", initial: execution_client.PayloadStatusNotValidated, next: execution_client.PayloadStatusNone, available: true, effective: execution_client.PayloadStatusNotValidated}, + {name: "optimistic to none", initial: execution_client.PayloadStatusNotValidated, next: execution_client.PayloadStatusNone, effective: execution_client.PayloadStatusNotValidated}, {name: "validated to none", initial: execution_client.PayloadStatusValidated, next: execution_client.PayloadStatusNone, available: true, verified: true, effective: execution_client.PayloadStatusValidated}, {name: "validated to optimistic", initial: execution_client.PayloadStatusValidated, next: execution_client.PayloadStatusNotValidated, available: true, verified: true, effective: execution_client.PayloadStatusValidated}, {name: "invalidated to none", initial: execution_client.PayloadStatusInvalidated, next: execution_client.PayloadStatusNone, effective: execution_client.PayloadStatusInvalidated}, @@ -795,7 +807,11 @@ func newPtcVoteTestStore(root common.Hash) *ForkChoiceStore { verifiedExecutionPayload.Add(root, struct{}{}) blocks := map[common.Hash]*cltypes.SignedBeaconBlock{root: block} envelopes := map[common.Hash]bool{root: true} - fg := ptcVoteForkGraph{envelopes: envelopes, blocks: blocks} + fg := ptcVoteForkGraph{ + envelopes: envelopes, + blocks: blocks, + acceptedPayloads: map[common.Hash]bool{root: true}, + } f := &ForkChoiceStore{ genesisTime: 0, beaconCfg: cfg, diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index 687e537b24c..638eac0bf2f 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -480,7 +480,6 @@ func validateExecutionRequestsLimits(cfg *clparams.BeaconChainConfig, requests * count int limit uint64 }{ - {"deposit requests", requests.Deposits.Len(), cfg.MaxDepositRequestsPerPayload}, {"withdrawal requests", requests.Withdrawals.Len(), cfg.MaxWithdrawalRequestsPerPayload}, {"consolidation requests", requests.Consolidations.Len(), cfg.MaxConsolidationRequestsPerPayload}, {"builder deposit requests", requests.BuilderDeposits.Len(), cfg.MaxBuilderDepositRequestsPerPayload}, diff --git a/cl/phase1/network/services/constants.go b/cl/phase1/network/services/constants.go index 5977b35aa5a..493729e9339 100644 --- a/cl/phase1/network/services/constants.go +++ b/cl/phase1/network/services/constants.go @@ -41,7 +41,6 @@ const ( var ( ErrIgnore = errors.New("ignore") // ErrIgnore is used to indicate that the message should be ignored. - ErrBidQueued = errors.New("bid queued") ErrAttestationQueued = errors.New("attestation queued") ErrBlockYoungerThanParent = errors.New("block is younger than parent") ErrInvalidCommitmentsCount = errors.New("invalid commitments count") diff --git a/cl/phase1/network/services/execution_payload_bid_service.go b/cl/phase1/network/services/execution_payload_bid_service.go index 2ed45aad6d7..1e591ffb2dd 100644 --- a/cl/phase1/network/services/execution_payload_bid_service.go +++ b/cl/phase1/network/services/execution_payload_bid_service.go @@ -22,7 +22,6 @@ import ( "fmt" "math" "sync" - "sync/atomic" "time" "github.com/erigontech/erigon/cl/beacon/beaconevents" @@ -92,18 +91,6 @@ func newSeenBidKey(bid *cltypes.ExecutionPayloadBid) seenBidKey { } } -// pendingBidKey tracks bids waiting for proposer preferences. -type pendingBidKey struct { - seenBidKey - messageRoot common.Hash -} - -// pendingBidJob represents a pending bid waiting for proposer preferences to arrive. -type pendingBidJob struct { - msg *cltypes.SignedExecutionPayloadBid - creationTime time.Time -} - type bidValidationStateKey struct { parentBlockRoot common.Hash slot uint64 @@ -119,8 +106,6 @@ type bidValidationStateEntry struct { var errBidDependencyUnavailable = fmt.Errorf("%w: bid dependency unavailable", ErrIgnore) const ( - maxPendingBids = 1024 - maxPendingBidsPerTuple = 4 bidValidationStateCacheSize = 4 bidValidationStateTTLSlots = 2 gloasMaximumClockDisparity = 500 * time.Millisecond @@ -139,13 +124,6 @@ type executionPayloadBidService struct { bidStoreMu sync.Mutex validationStateMu sync.Mutex validationStateCache *lru.CacheWithTTL[bidValidationStateKey, *bidValidationStateEntry] - - // Pending bids waiting for proposer preferences - pendingBids sync.Map // pendingBidKey -> *pendingBidJob - pendingMu sync.Mutex - pendingByKey map[seenBidKey]int - pendingCount atomic.Int32 - pendingCond *sync.Cond } // NewExecutionPayloadBidService creates a new execution payload bid gossip service. @@ -174,8 +152,6 @@ func NewExecutionPayloadBidService( now: time.Now, seenCache: newSeenBidStore(), validationStateCache: validationStateCache, - pendingCond: sync.NewCond(&sync.Mutex{}), - pendingByKey: make(map[seenBidKey]int), } return s } @@ -630,64 +606,3 @@ func (s *executionPayloadBidService) validateBuilderAvailability( } return builder, nil } - -// queuePendingBid adds a bid to the pending queue for later processing when preferences arrive. -func (s *executionPayloadBidService) queuePendingBid(msg *cltypes.SignedExecutionPayloadBid) bool { - key := pendingBidKeyFor(msg) - job := &pendingBidJob{ - msg: msg, - creationTime: time.Now(), - } - - s.pendingMu.Lock() - if s.pendingByKey == nil { - s.pendingByKey = make(map[seenBidKey]int) - } - if _, loaded := s.pendingBids.Load(key); loaded { - s.pendingMu.Unlock() - return false - } - if s.pendingCount.Load() >= maxPendingBids { - s.pendingMu.Unlock() - return false - } - tuple := key.seenBidKey - if s.pendingByKey[tuple] >= maxPendingBidsPerTuple { - s.pendingMu.Unlock() - return false - } - s.pendingBids.Store(key, job) - s.pendingByKey[tuple]++ - s.pendingCount.Add(1) - s.pendingMu.Unlock() - - s.signalPendingBids() - return true -} - -func (s *executionPayloadBidService) deletePendingBid(key pendingBidKey, job *pendingBidJob) bool { - s.pendingMu.Lock() - defer s.pendingMu.Unlock() - current, ok := s.pendingBids.Load(key) - if !ok || current != job { - return false - } - s.pendingBids.Delete(key) - s.pendingByKey[key.seenBidKey]-- - if s.pendingByKey[key.seenBidKey] == 0 { - delete(s.pendingByKey, key.seenBidKey) - } - s.pendingCount.Add(-1) - return true -} - -func (s *executionPayloadBidService) signalPendingBids() { - s.pendingCond.L.Lock() - s.pendingCond.Signal() - s.pendingCond.L.Unlock() -} - -func pendingBidKeyFor(msg *cltypes.SignedExecutionPayloadBid) pendingBidKey { - root, _ := msg.HashSSZ() - return pendingBidKey{seenBidKey: newSeenBidKey(msg.Message), messageRoot: common.Hash(root)} -} diff --git a/cl/phase1/network/services/execution_payload_bid_service_test.go b/cl/phase1/network/services/execution_payload_bid_service_test.go index 0348e1e613e..a47e92a149c 100644 --- a/cl/phase1/network/services/execution_payload_bid_service_test.go +++ b/cl/phase1/network/services/execution_payload_bid_service_test.go @@ -5,7 +5,6 @@ import ( "encoding/binary" "errors" "math" - "sync" "testing" "time" @@ -83,7 +82,6 @@ func setupExecutionPayloadBidService(t *testing.T, ctrl *gomock.Controller) ( }, seenCache: newSeenBidStore(), validationStateCache: validationStateCache, - pendingCond: sync.NewCond(&sync.Mutex{}), } return service, mockSyncedData, ethClockMock, fcMock, epbsPool @@ -472,7 +470,6 @@ func TestExecutionPayloadBidServiceRejectsNonZeroExecutionPaymentWithMissingStat require.Error(t, err) require.Contains(t, err.Error(), "execution_payment must be 0") - require.Equal(t, int32(0), service.pendingCount.Load()) } func TestExecutionPayloadBidServiceRejectsTooManyBlobCommitmentsWithMissingStateBeforeQueue(t *testing.T) { @@ -493,7 +490,6 @@ func TestExecutionPayloadBidServiceRejectsTooManyBlobCommitmentsWithMissingState require.Error(t, err) require.Contains(t, err.Error(), "too many blob_kzg_commitments") - require.Equal(t, int32(0), service.pendingCount.Load()) } func TestExecutionPayloadBidServiceWaitsForMatchingDependentRootPreference(t *testing.T) { @@ -516,8 +512,6 @@ func TestExecutionPayloadBidServiceWaitsForMatchingDependentRootPreference(t *te ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) err := service.ProcessMessage(context.Background(), nil, msg) require.ErrorIs(t, err, ErrIgnore) - require.NotErrorIs(t, err, ErrBidQueued) - require.Equal(t, int32(0), service.pendingCount.Load()) addPreferencesToPool(epbsPool, 100) fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated @@ -525,7 +519,6 @@ func TestExecutionPayloadBidServiceWaitsForMatchingDependentRootPreference(t *te ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) require.NoError(t, service.ProcessMessage(context.Background(), nil, msg)) - require.Equal(t, int32(0), service.pendingCount.Load()) _, found := epbsPool.HighestBids.Get(pool.HighestBidKey{Slot: 100, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot}) require.True(t, found) } @@ -544,8 +537,6 @@ func TestExecutionPayloadBidServiceMissingParentStateIsNotQueued(t *testing.T) { ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) err := service.ProcessMessage(context.Background(), nil, msg) require.ErrorIs(t, err, ErrIgnore) - require.NotErrorIs(t, err, ErrBidQueued) - require.Zero(t, service.pendingCount.Load()) _, found := epbsPool.HighestBids.Get(pool.HighestBidKey{Slot: 100, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot}) require.False(t, found) } @@ -979,7 +970,6 @@ func TestExecutionPayloadBidServiceRejectsLowerBidBeforeStateFetch(t *testing.T) require.True(t, errors.Is(err, ErrIgnore)) require.Contains(t, err.Error(), "not higher than existing") require.Zero(t, service.validationStateCache.Len()) - require.Equal(t, int32(0), service.pendingCount.Load()) } func TestExecutionPayloadBidServiceSameBuilderDistinctCompatibleParents(t *testing.T) { @@ -1055,27 +1045,7 @@ func TestExecutionPayloadBidServiceSuccess(t *testing.T) { require.Equal(t, msg, stored) } -func TestExecutionPayloadBidServicePendingQueueCap(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl) - - // Fill the queue to the cap - service.pendingCount.Store(maxPendingBids) - - msg := newTestSignedExecutionPayloadBid(100, 999, 1000) - - require.False(t, service.queuePendingBid(msg)) - - // Should still be at cap — new item was rejected - require.Equal(t, int32(maxPendingBids), service.pendingCount.Load()) - key := pendingBidKeyFor(msg) - _, exists := service.pendingBids.Load(key) - require.False(t, exists) -} - -func TestExecutionPayloadBidServiceDoesNotQueueWhenPreferencesAreMissing(t *testing.T) { +func TestExecutionPayloadBidServiceRejectsWhenPreferencesAreMissing(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() service, _, ethClock, _, _ := setupExecutionPayloadBidService(t, ctrl) @@ -1084,32 +1054,10 @@ func TestExecutionPayloadBidServiceDoesNotQueueWhenPreferencesAreMissing(t *test err := service.ProcessMessage(context.Background(), nil, msg) require.ErrorIs(t, err, ErrIgnore) - require.NotErrorIs(t, err, ErrBidQueued) require.Contains(t, err.Error(), "proposer preferences not available") - require.Zero(t, service.pendingCount.Load()) -} - -func TestExecutionPayloadBidServicePendingQueueKeepsBoundedSignatureVariants(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl) - first := newTestSignedExecutionPayloadBid(100, 1, 1000) - second := newTestSignedExecutionPayloadBid(100, 1, 1000) - second.Signature[0] = 1 - - require.True(t, service.queuePendingBid(first)) - require.False(t, service.queuePendingBid(first)) - require.True(t, service.queuePendingBid(second)) - - require.Equal(t, int32(2), service.pendingCount.Load()) - stored, firstExists := service.pendingBids.Load(pendingBidKeyFor(first)) - require.True(t, firstExists) - require.Same(t, first, stored.(*pendingBidJob).msg) - require.NotEqual(t, pendingBidKeyFor(first), pendingBidKeyFor(second)) } -func TestExecutionPayloadBidServiceInvalidSignaturesCannotFillPendingTuple(t *testing.T) { +func TestExecutionPayloadBidServiceRejectsInvalidSignatureVariantsBeforeValidBid(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -1119,18 +1067,16 @@ func TestExecutionPayloadBidServiceInvalidSignaturesCannotFillPendingTuple(t *te fc.Headers[valid.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{Slot: 99} fc.ExecutionPayloadStatusMap[valid.Message.ParentBlockHash] = execution_client.PayloadStatusValidated addPreferencesToPool(epbsPool, 100) - ethClock.EXPECT().GetCurrentSlot().Return(uint64(100)).Times(maxPendingBidsPerTuple + 2) + const invalidVariants = 5 + ethClock.EXPECT().GetCurrentSlot().Return(uint64(100)).Times(invalidVariants + 1) - for i := range maxPendingBidsPerTuple + 1 { + for i := range invalidVariants { invalid := newTestSignedExecutionPayloadBid(100, 1, 1000) invalid.Signature[0] = byte(i + 1) err := service.ProcessMessage(context.Background(), nil, invalid) require.ErrorContains(t, err, "invalid builder signature") - require.NotErrorIs(t, err, ErrBidQueued) } - require.Zero(t, service.pendingCount.Load()) require.NoError(t, service.ProcessMessage(context.Background(), nil, valid)) - require.Zero(t, service.pendingCount.Load()) stored, ok := epbsPool.HighestBids.Get(pool.HighestBidKey{ Slot: valid.Message.Slot, @@ -1139,30 +1085,6 @@ func TestExecutionPayloadBidServiceInvalidSignaturesCannotFillPendingTuple(t *te }) require.True(t, ok) require.Equal(t, valid.Signature, stored.Signature) - require.Zero(t, service.pendingCount.Load()) -} - -func TestExecutionPayloadBidServiceDeletePendingBidDoesNotRemoveOtherSameBuilderSlot(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl) - first := newTestSignedExecutionPayloadBid(100, 1, 1000) - second := newTestSignedExecutionPayloadBid(100, 1, 2000) - second.Message.ParentBlockRoot = common.HexToHash("0xcccc") - - service.queuePendingBid(first) - firstKey := pendingBidKeyFor(first) - firstJob, exists := service.pendingBids.Load(firstKey) - require.True(t, exists) - - service.queuePendingBid(second) - require.True(t, service.deletePendingBid(firstKey, firstJob.(*pendingBidJob))) - require.Equal(t, int32(1), service.pendingCount.Load()) - - current, exists := service.pendingBids.Load(pendingBidKeyFor(second)) - require.True(t, exists) - require.Same(t, second, current.(*pendingBidJob).msg) } func TestExecutionPayloadBidServiceRejectsNonAdvancingKnownParentBeforeQueue(t *testing.T) { @@ -1176,34 +1098,6 @@ func TestExecutionPayloadBidServiceRejectsNonAdvancingKnownParentBeforeQueue(t * err := service.ProcessMessage(context.Background(), nil, msg) require.Error(t, err) - require.NotErrorIs(t, err, ErrBidQueued) - require.Zero(t, service.pendingCount.Load()) -} - -func TestExecutionPayloadBidServicePendingQueueCapConcurrent(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl) - - service.pendingCount.Store(maxPendingBids - 5) - - var wg sync.WaitGroup - for i := range 100 { - wg.Go(func() { - msg := newTestSignedExecutionPayloadBid(uint64(10000+i), uint64(i), 1000) - service.queuePendingBid(msg) - }) - } - wg.Wait() - - require.Equal(t, int32(maxPendingBids), service.pendingCount.Load()) - stored := 0 - service.pendingBids.Range(func(_, _ any) bool { - stored++ - return true - }) - require.Equal(t, 5, stored) } func TestExecutionPayloadBidServiceDecodeGossipMessage(t *testing.T) { diff --git a/cl/phase1/network/services/execution_payload_service_test.go b/cl/phase1/network/services/execution_payload_service_test.go index d53b79b35df..bbcd6c69bb9 100644 --- a/cl/phase1/network/services/execution_payload_service_test.go +++ b/cl/phase1/network/services/execution_payload_service_test.go @@ -609,16 +609,36 @@ func TestExecutionPayloadServiceNames(t *testing.T) { require.False(t, impl.IsMyGossipMessage("beacon_block")) } -func TestValidateEnvelopeLimitsRejectsOversizedRequestsAndWithdrawals(t *testing.T) { +func TestValidateEnvelopeLimitsDoesNotApplyLegacyDepositRequestMaximum(t *testing.T) { cfg := clparams.MainnetBeaconConfig cfg.MaxDepositRequestsPerPayload = 1 - cfg.MaxWithdrawalsPerPayload = 1 envelope := cltypes.NewExecutionPayloadEnvelope(&cfg) envelope.ExecutionRequests.Deposits.Append(&solid.DepositRequest{}) envelope.ExecutionRequests.Deposits.Append(&solid.DepositRequest{}) - require.Error(t, validateEnvelopeLimits(&cfg, envelope)) + envelope.Payload.Withdrawals = solid.NewStaticListSSZ[*cltypes.Withdrawal](int(cfg.MaxWithdrawalsPerPayload), 44) + require.NoError(t, validateEnvelopeLimits(&cfg, envelope)) +} - envelope = cltypes.NewExecutionPayloadEnvelope(&cfg) +func TestExecutionPayloadServiceDecodesProgressiveDepositRequestsAboveLegacyGuard(t *testing.T) { + service, _ := setupExecutionPayloadService(t) + envelope := newTestSignedEnvelope(100, common.Hash{1}, 1) + const depositCount = 16_385 + for range depositCount { + envelope.Message.ExecutionRequests.Deposits.Append(&solid.DepositRequest{}) + } + encoded, err := envelope.EncodeSSZ(nil) + require.NoError(t, err) + require.Less(t, uint64(len(encoded)), clparams.MaxChunkSize) + + decoded, err := service.DecodeGossipMessage("peer123", encoded, clparams.GloasVersion) + require.NoError(t, err) + require.Equal(t, depositCount, decoded.Message.ExecutionRequests.Deposits.Len()) +} + +func TestValidateEnvelopeLimitsRejectsOversizedRequestsAndWithdrawals(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + cfg.MaxWithdrawalsPerPayload = 1 + envelope := cltypes.NewExecutionPayloadEnvelope(&cfg) envelope.Payload.Withdrawals = solid.NewStaticListSSZ[*cltypes.Withdrawal](16, 44) envelope.Payload.Withdrawals.Append(&cltypes.Withdrawal{}) envelope.Payload.Withdrawals.Append(&cltypes.Withdrawal{}) From f562e88a3573223fbefaf9fceffda86e77a51ccb Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 15:48:56 +0800 Subject: [PATCH 21/43] ci: use minimal Prysm images for Gloas --- .github/workflows/kurtosis/gloas-three-cl-mixed.io | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/kurtosis/gloas-three-cl-mixed.io b/.github/workflows/kurtosis/gloas-three-cl-mixed.io index 69b8be989fa..c41589a2ca3 100644 --- a/.github/workflows/kurtosis/gloas-three-cl-mixed.io +++ b/.github/workflows/kurtosis/gloas-three-cl-mixed.io @@ -8,14 +8,14 @@ participants: supernode: true count: 1 - cl_type: prysm - cl_image: ethpandaops/prysm-beacon-chain:glamsterdam-devnet-8 + cl_image: ethpandaops/prysm-beacon-chain:glamsterdam-devnet-8-minimal el_type: erigon el_image: test/erigon:current el_log_level: "debug" el_extra_params: ["--experimental.bal"] use_separate_vc: true vc_type: prysm - vc_image: ethpandaops/prysm-validator:glamsterdam-devnet-8 + vc_image: ethpandaops/prysm-validator:glamsterdam-devnet-8-minimal count: 1 - cl_type: caplin cl_image: test/erigon:current From 29c6d1e0d1064721b65b97c2fafc7880dc2f700c Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 17:12:27 +0800 Subject: [PATCH 22/43] cl/phase1: isolate event feed progress test --- .../network/services/proposer_preferences_service_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cl/phase1/network/services/proposer_preferences_service_test.go b/cl/phase1/network/services/proposer_preferences_service_test.go index 5ac9f586c86..b26f854f5bc 100644 --- a/cl/phase1/network/services/proposer_preferences_service_test.go +++ b/cl/phase1/network/services/proposer_preferences_service_test.go @@ -290,7 +290,11 @@ func TestProposerPreferencesServiceEmitsEvent(t *testing.T) { func TestProposerPreferencesServiceProgressesWhileEventFeedIsBlocked(t *testing.T) { ctrl := gomock.NewController(t) - service, _, _, epbsPool, _ := setupProposerPreferencesService(t, ctrl) + service, _, _, epbsPool, forkChoice := setupProposerPreferencesService(t, ctrl) + validationState := forkChoice.StateAtBlockRootVal[testDependentRoot] + forkChoice.GetStateAtBlockRootFn = func(common.Hash, bool) (*state2.CachingBeaconState, error) { + return validationState, nil + } emitter := beaconevents.NewEventEmitter() service.emitters = emitter slow := make(chan *beaconevents.EventStream) From efce1a95019b208b81514b982feeccba5099b9c0 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 20:43:56 +0800 Subject: [PATCH 23/43] cl: fix minimal validator compatibility --- .../kurtosis/caplin-minimal-assertoor.io | 7 +-- cl/beacon/handler/validator_test.go | 52 +++++++++++++++++++ cl/beacon/handler/validators.go | 13 ++++- cl/phase1/network/services/block_service.go | 6 +-- .../network/services/block_service_test.go | 27 ++++++++++ 5 files changed, 94 insertions(+), 11 deletions(-) diff --git a/.github/workflows/kurtosis/caplin-minimal-assertoor.io b/.github/workflows/kurtosis/caplin-minimal-assertoor.io index 2f63c98d079..825a7ea1da4 100644 --- a/.github/workflows/kurtosis/caplin-minimal-assertoor.io +++ b/.github/workflows/kurtosis/caplin-minimal-assertoor.io @@ -6,11 +6,8 @@ participants: cl_image: test/erigon:current cl_log_level: "debug" use_separate_vc: true - vc_type: lighthouse - # Keep lighthouse v7.0.1 here: v8 submits attestations only as Electra - # SingleAttestation, which caplin's pre-Electra pool endpoint rejects on - # this Deneb network (the pinned ethereum-package can't do Electra genesis). - vc_image: sigp/lighthouse:v7.0.1 + vc_type: lodestar + vc_image: chainsafe/lodestar:v1.46.0 network_params: preset: "minimal" deneb_fork_epoch: 0 diff --git a/cl/beacon/handler/validator_test.go b/cl/beacon/handler/validator_test.go index 0aed57fad3d..ead23dec2a3 100644 --- a/cl/beacon/handler/validator_test.go +++ b/cl/beacon/handler/validator_test.go @@ -28,8 +28,11 @@ import ( mockaggregation "github.com/erigontech/erigon/cl/aggregation/mock_services" "github.com/erigontech/erigon/cl/beacon/beacon_router_configuration" + "github.com/erigontech/erigon/cl/beacon/beaconhttp" + "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes/solid" "github.com/erigontech/erigon/cl/pool" + "github.com/erigontech/erigon/cl/utils/eth_clock" "github.com/erigontech/erigon/common" ) @@ -218,6 +221,55 @@ func (t *validatorTestSuite) TestGetEthV1ValidatorAggregateAttestation() { } } +func (t *validatorTestSuite) TestGetEthV2ValidatorAggregateAttestationUsesPreElectraPool() { + clock := eth_clock.NewMockEthereumClock(t.gomockCtrl) + t.apiHandler.ethClock = clock + t.apiHandler.beaconChainCfg = &clparams.BeaconChainConfig{SlotsPerEpoch: 8} + + dataRoot := common.HexToHash("0x123") + attestation := &solid.Attestation{Data: &solid.AttestationData{Slot: 1}} + clock.EXPECT().StateVersionByEpoch(uint64(0)).Return(clparams.DenebVersion) + t.mockAggrPool.EXPECT().GetAggregatationByRoot(dataRoot).Return(attestation) + + req := httptest.NewRequestWithContext(t.T().Context(), http.MethodGet, "/eth/v2/validator/aggregate_attestation?attestation_data_root="+dataRoot.String()+"&slot=1&committee_index=0", nil) + response, err := t.apiHandler.GetEthV2ValidatorAggregateAttestation(httptest.NewRecorder(), req) + t.Require().NoError(err) + t.Require().Same(attestation, response.Data) +} + +func (t *validatorTestSuite) TestGetEthV2ValidatorAggregateAttestationRejectsPreElectraCommitteeMismatch() { + clock := eth_clock.NewMockEthereumClock(t.gomockCtrl) + t.apiHandler.ethClock = clock + t.apiHandler.beaconChainCfg = &clparams.BeaconChainConfig{SlotsPerEpoch: 8} + + dataRoot := common.HexToHash("0x123") + attestation := &solid.Attestation{Data: &solid.AttestationData{Slot: 1, CommitteeIndex: 0}} + clock.EXPECT().StateVersionByEpoch(uint64(0)).Return(clparams.DenebVersion) + t.mockAggrPool.EXPECT().GetAggregatationByRoot(dataRoot).Return(attestation) + + req := httptest.NewRequestWithContext(t.T().Context(), http.MethodGet, "/eth/v2/validator/aggregate_attestation?attestation_data_root="+dataRoot.String()+"&slot=1&committee_index=3", nil) + _, err := t.apiHandler.GetEthV2ValidatorAggregateAttestation(httptest.NewRecorder(), req) + var endpointErr *beaconhttp.EndpointError + t.Require().ErrorAs(err, &endpointErr) + t.Require().Equal(http.StatusBadRequest, endpointErr.Code) +} + +func (t *validatorTestSuite) TestGetEthV2ValidatorAggregateAttestationUsesCommitteePoolAfterElectra() { + clock := eth_clock.NewMockEthereumClock(t.gomockCtrl) + t.apiHandler.ethClock = clock + t.apiHandler.beaconChainCfg = &clparams.BeaconChainConfig{SlotsPerEpoch: 8} + + dataRoot := common.HexToHash("0x123") + attestation := &solid.Attestation{Data: &solid.AttestationData{Slot: 8}} + clock.EXPECT().StateVersionByEpoch(uint64(1)).Return(clparams.ElectraVersion) + t.mockAggrPool.EXPECT().GetAggregatationByRootAndCommittee(dataRoot, uint64(3)).Return(attestation) + + req := httptest.NewRequestWithContext(t.T().Context(), http.MethodGet, "/eth/v2/validator/aggregate_attestation?attestation_data_root="+dataRoot.String()+"&slot=8&committee_index=3", nil) + response, err := t.apiHandler.GetEthV2ValidatorAggregateAttestation(httptest.NewRecorder(), req) + t.Require().NoError(err) + t.Require().Same(attestation, response.Data) +} + func TestValidator(t *testing.T) { suite.Run(t, new(validatorTestSuite)) } diff --git a/cl/beacon/handler/validators.go b/cl/beacon/handler/validators.go index 3b5c7f6fed7..37b9e1b41a9 100644 --- a/cl/beacon/handler/validators.go +++ b/cl/beacon/handler/validators.go @@ -31,6 +31,7 @@ import ( "github.com/erigontech/erigon/cl/beacon/beaconhttp" "github.com/erigontech/erigon/cl/beacon/synced_data" + "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes/solid" "github.com/erigontech/erigon/cl/persistence/beacon_indicies" state_accessors "github.com/erigontech/erigon/cl/persistence/state" @@ -775,16 +776,24 @@ func (a *ApiHandler) GetEthV2ValidatorAggregateAttestation(w http.ResponseWriter } attDataRootHash := common.HexToHash(attDataRoot) - att := a.aggregatePool.GetAggregatationByRootAndCommittee(attDataRootHash, committeeIndexNum) + version := a.ethClock.StateVersionByEpoch(slotNum / a.beaconChainCfg.SlotsPerEpoch) + var att *solid.Attestation + if version.Before(clparams.ElectraVersion) { + att = a.aggregatePool.GetAggregatationByRoot(attDataRootHash) + } else { + att = a.aggregatePool.GetAggregatationByRootAndCommittee(attDataRootHash, committeeIndexNum) + } if att == nil { return nil, beaconhttp.NewEndpointError(http.StatusNotFound, fmt.Errorf("attestation %s not found", attDataRoot)) } + if version.Before(clparams.ElectraVersion) && committeeIndexNum != att.Data.CommitteeIndex { + return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("attestation committee index mismatch")) + } if slotNum != att.Data.Slot { log.Debug("attestation slot does not match", "attestation_data_root", attDataRoot, "slot_inquire", slot) return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("attestation slot mismatch")) } - version := a.ethClock.StateVersionByEpoch(slotNum / a.beaconChainCfg.SlotsPerEpoch) return newBeaconResponse(att).WithVersion(version), nil } diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index 638eac0bf2f..8ddca261648 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -314,20 +314,18 @@ func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeac return err } } - var finalizedCheckpoint solid.Checkpoint + finalizedCheckpoint := b.forkchoiceStore.FinalizedCheckpoint() if err := b.syncedData.ViewHeadState(func(headState *state.CachingBeaconState) error { // [IGNORE] The block is from a slot greater than the latest finalized slot -- i.e. validate that signed_beacon_block.message.slot > compute_start_slot_at_epoch(store.finalized_checkpoint.epoch) // (a client MAY choose to validate and store such blocks for additional purposes -- e.g. slashing detection, archive nodes, etc). - finalizedStartSlot, ok := safeMultiplyUint64(headState.FinalizedCheckpoint().Epoch, b.beaconCfg.SlotsPerEpoch) + finalizedStartSlot, ok := safeMultiplyUint64(finalizedCheckpoint.Epoch, b.beaconCfg.SlotsPerEpoch) if !ok { return errors.New("finalized checkpoint slot is not representable") } if msg.Block.Slot <= finalizedStartSlot { return fmt.Errorf("%w: block slot %d is not after finalized slot %d", ErrIgnore, msg.Block.Slot, finalizedStartSlot) } - finalizedCheckpoint = headState.FinalizedCheckpoint() - if ok, err := eth2.VerifyBlockSignature(headState, msg); err != nil { return err } else if !ok { diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index 81606c578fb..1de3a390f99 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -224,6 +224,33 @@ func TestBlockServiceGossipUsesCheckpointSyncAnchorForFinalizedAncestor(t *testi require.NoError(t, blockService.ValidateGossip(t.Context(), blocks[1])) } +func TestBlockServiceGossipUsesForkChoiceFinalizedCheckpointAtGenesis(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + blocks, pre, post := tests.GetBellatrixRandom() + parentState, err := pre.Copy() + require.NoError(t, err) + require.NoError(t, transition.TransitionState(parentState, blocks[0], nil, false)) + headState, err := post.Copy() + require.NoError(t, err) + headState.SetFinalizedCheckpoint(solid.Checkpoint{}) + + blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl) + require.NoError(t, syncedData.OnHeadState(headState)) + ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes() + ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes() + anchorRoot := blocks[1].Block.ParentRoot + fcu.FinalizedCheckpointVal = solid.Checkpoint{Root: anchorRoot} + fcu.Headers[anchorRoot] = blocks[0].SignedBeaconBlockHeader().Header.Copy() + fcu.StateAtBlockRootVal[anchorRoot] = parentState + fcu.AnchorRootVal = anchorRoot + fcu.AnchorSlotVal = blocks[0].Block.Slot + fcu.Ancestors[fcu.AnchorSlotVal] = forkchoice.ForkChoiceNode{Root: anchorRoot} + + require.NoError(t, blockService.ValidateGossip(t.Context(), blocks[1])) +} + func TestBlockServiceGossipRejectsUnexpectedProposer(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() From 9eaa21ca5b780e17eb43788bf64f79cece783cb2 Mon Sep 17 00:00:00 2001 From: kewei Date: Wed, 26 Aug 2026 22:42:27 +0800 Subject: [PATCH 24/43] cl: fix Gloas event and CI boundaries --- .github/workflows/test-kurtosis-assertoor.yml | 35 +++++++++++-------- cl/cltypes/gloas_api.go | 4 +-- cl/cltypes/gloas_api_test.go | 2 ++ .../services/execution_payload_service.go | 5 ++- .../execution_payload_service_test.go | 32 +++++++++++++++++ cl/phase1/stages/forkchoice.go | 8 ++--- cl/phase1/stages/forkchoice_test.go | 25 +++++++++++++ 7 files changed, 89 insertions(+), 22 deletions(-) diff --git a/.github/workflows/test-kurtosis-assertoor.yml b/.github/workflows/test-kurtosis-assertoor.yml index 66fff6ca3c0..7e88457e3ae 100644 --- a/.github/workflows/test-kurtosis-assertoor.yml +++ b/.github/workflows/test-kurtosis-assertoor.yml @@ -225,7 +225,8 @@ jobs: teku=$(yq -e '.participants_matrix.cl[] | select(.cl_type == "teku") | .cl_image' "$reg") genesis_glamsterdam=$(yq -e '.ethereum_genesis_generator_params.image' "$glamsterdam_io") spamoor_glamsterdam=$(yq -e '.spamoor_params.image' "$glamsterdam_io") - lighthouse_vc=$(yq -e '.participants[] | select(.vc_type == "lighthouse") | .vc_image' "$caplin_io") + pectra_lighthouse=$(yq -e '.participants_matrix.cl[] | select(.cl_type == "lighthouse") | .cl_image' "$pectra_io") + minimal_vc=$(yq -e '.participants[] | select(.use_separate_vc == true) | .vc_image' "$caplin_io") # pectra's clients and caplin-minimal's assertoor are warmed under the tag # another suite pins. Bumping one alone leaves it out of the cache, which # is a slow path rather than a failure: warn, and hand the tag to the @@ -239,8 +240,6 @@ jobs: } check_warmed "$pectra_io" \ "$(yq -e '.participants_matrix.cl[] | select(.cl_type == "teku") | .cl_image' "$pectra_io")" "$teku" - check_warmed "$pectra_io" \ - "$(yq -e '.participants_matrix.cl[] | select(.cl_type == "lighthouse") | .cl_image' "$pectra_io")" "$lighthouse_vc" check_warmed "$caplin_io" "$(yq -e '.assertoor_params.image' "$caplin_io")" "$assertoor" echo "UNCACHED_IMAGES=${uncached# }" >> "$GITHUB_ENV" { @@ -251,13 +250,14 @@ jobs: echo "TEKU_IMAGE=$teku" echo "GLAMSTERDAM_GENESIS_GENERATOR_IMAGE=$genesis_glamsterdam" echo "GLAMSTERDAM_SPAMOOR_IMAGE=$spamoor_glamsterdam" - echo "LIGHTHOUSE_VC_IMAGE=$lighthouse_vc" + echo "PECTRA_LIGHTHOUSE_IMAGE=$pectra_lighthouse" + echo "MINIMAL_VC_IMAGE=$minimal_vc" } >> "$GITHUB_ENV" # Every cached tag stays spelled out in the cache key so a glance at the # Actions cache list shows what a key holds. Only the registry/org prefix # is dropped, which keeps the key clear of actions/cache's 512-char cap. key=docker-cl - for image in "$lighthouse" "$lighthouse_vc" "$teku" "$assertoor" \ + for image in "$lighthouse" "$pectra_lighthouse" "$minimal_vc" "$teku" "$assertoor" \ "kurtosis:$KURTOSIS_VERSION" "$KURTOSIS_VECTOR_IMAGE" "$KURTOSIS_FLUENTBIT_IMAGE" \ "$KURTOSIS_CURL_JQ_IMAGE" "$KURTOSIS_TRAEFIK_IMAGE" "$KURTOSIS_ALPINE_IMAGE" \ "$ETH2_VAL_TOOLS_IMAGE" "$KURTOSIS_SNOOPER_IMAGE" "$pectra" "$glamsterdam" \ @@ -290,7 +290,8 @@ jobs: return 1 } pull "${LIGHTHOUSE_IMAGE}" - pull "${LIGHTHOUSE_VC_IMAGE}" + pull "${PECTRA_LIGHTHOUSE_IMAGE}" + pull "${MINIMAL_VC_IMAGE}" pull "${TEKU_IMAGE}" pull "${ASSERTOOR_IMAGE}" pull "${ASSERTOOR_PECTRA_IMAGE}" @@ -311,7 +312,8 @@ jobs: pull "${KURTOSIS_SNOOPER_IMAGE}" pull "${KURTOSIS_PYTHON_IMAGE}" docker save "${LIGHTHOUSE_IMAGE}" -o /tmp/docker-cache/lighthouse.tar - docker save "${LIGHTHOUSE_VC_IMAGE}" -o /tmp/docker-cache/lighthouse-vc.tar + docker save "${PECTRA_LIGHTHOUSE_IMAGE}" -o /tmp/docker-cache/pectra-lighthouse.tar + docker save "${MINIMAL_VC_IMAGE}" -o /tmp/docker-cache/minimal-vc.tar docker save "${TEKU_IMAGE}" -o /tmp/docker-cache/teku.tar docker save "${ASSERTOOR_IMAGE}" -o /tmp/docker-cache/assertoor.tar docker save "${ASSERTOOR_PECTRA_IMAGE}" -o /tmp/docker-cache/assertoor-pectra.tar @@ -481,7 +483,8 @@ jobs: teku=$(yq -e '.participants_matrix.cl[] | select(.cl_type == "teku") | .cl_image' "$reg") genesis_glamsterdam=$(yq -e '.ethereum_genesis_generator_params.image' "$glamsterdam_io") spamoor_glamsterdam=$(yq -e '.spamoor_params.image' "$glamsterdam_io") - lighthouse_vc=$(yq -e '.participants[] | select(.vc_type == "lighthouse") | .vc_image' "$caplin_io") + pectra_lighthouse=$(yq -e '.participants_matrix.cl[] | select(.cl_type == "lighthouse") | .cl_image' "$pectra_io") + minimal_vc=$(yq -e '.participants[] | select(.use_separate_vc == true) | .vc_image' "$caplin_io") # pectra's clients and caplin-minimal's assertoor are warmed under the tag # another suite pins. Bumping one alone leaves it out of the cache, which # is a slow path rather than a failure: warn, and hand the tag to the @@ -495,8 +498,6 @@ jobs: } check_warmed "$pectra_io" \ "$(yq -e '.participants_matrix.cl[] | select(.cl_type == "teku") | .cl_image' "$pectra_io")" "$teku" - check_warmed "$pectra_io" \ - "$(yq -e '.participants_matrix.cl[] | select(.cl_type == "lighthouse") | .cl_image' "$pectra_io")" "$lighthouse_vc" check_warmed "$caplin_io" "$(yq -e '.assertoor_params.image' "$caplin_io")" "$assertoor" echo "UNCACHED_IMAGES=${uncached# }" >> "$GITHUB_ENV" { @@ -507,13 +508,14 @@ jobs: echo "TEKU_IMAGE=$teku" echo "GLAMSTERDAM_GENESIS_GENERATOR_IMAGE=$genesis_glamsterdam" echo "GLAMSTERDAM_SPAMOOR_IMAGE=$spamoor_glamsterdam" - echo "LIGHTHOUSE_VC_IMAGE=$lighthouse_vc" + echo "PECTRA_LIGHTHOUSE_IMAGE=$pectra_lighthouse" + echo "MINIMAL_VC_IMAGE=$minimal_vc" } >> "$GITHUB_ENV" # Every cached tag stays spelled out in the cache key so a glance at the # Actions cache list shows what a key holds. Only the registry/org prefix # is dropped, which keeps the key clear of actions/cache's 512-char cap. key=docker-cl - for image in "$lighthouse" "$lighthouse_vc" "$teku" "$assertoor" \ + for image in "$lighthouse" "$pectra_lighthouse" "$minimal_vc" "$teku" "$assertoor" \ "kurtosis:$KURTOSIS_VERSION" "$KURTOSIS_VECTOR_IMAGE" "$KURTOSIS_FLUENTBIT_IMAGE" \ "$KURTOSIS_CURL_JQ_IMAGE" "$KURTOSIS_TRAEFIK_IMAGE" "$KURTOSIS_ALPINE_IMAGE" \ "$ETH2_VAL_TOOLS_IMAGE" "$KURTOSIS_SNOOPER_IMAGE" "$pectra" "$glamsterdam" \ @@ -536,7 +538,8 @@ jobs: if: steps.cache-cl-images.outputs.cache-hit == 'true' run: | docker load -i /tmp/docker-cache/lighthouse.tar - docker load -i /tmp/docker-cache/lighthouse-vc.tar + docker load -i /tmp/docker-cache/pectra-lighthouse.tar + docker load -i /tmp/docker-cache/minimal-vc.tar docker load -i /tmp/docker-cache/teku.tar docker load -i /tmp/docker-cache/assertoor.tar docker load -i /tmp/docker-cache/assertoor-pectra.tar @@ -570,7 +573,8 @@ jobs: return 1 } pull "${LIGHTHOUSE_IMAGE}" - pull "${LIGHTHOUSE_VC_IMAGE}" + pull "${PECTRA_LIGHTHOUSE_IMAGE}" + pull "${MINIMAL_VC_IMAGE}" pull "${TEKU_IMAGE}" pull "${ASSERTOOR_IMAGE}" pull "${ASSERTOOR_PECTRA_IMAGE}" @@ -591,7 +595,8 @@ jobs: pull "${KURTOSIS_SNOOPER_IMAGE}" pull "${KURTOSIS_PYTHON_IMAGE}" docker save "${LIGHTHOUSE_IMAGE}" -o /tmp/docker-cache/lighthouse.tar - docker save "${LIGHTHOUSE_VC_IMAGE}" -o /tmp/docker-cache/lighthouse-vc.tar + docker save "${PECTRA_LIGHTHOUSE_IMAGE}" -o /tmp/docker-cache/pectra-lighthouse.tar + docker save "${MINIMAL_VC_IMAGE}" -o /tmp/docker-cache/minimal-vc.tar docker save "${TEKU_IMAGE}" -o /tmp/docker-cache/teku.tar docker save "${ASSERTOOR_IMAGE}" -o /tmp/docker-cache/assertoor.tar docker save "${ASSERTOOR_PECTRA_IMAGE}" -o /tmp/docker-cache/assertoor-pectra.tar diff --git a/cl/cltypes/gloas_api.go b/cl/cltypes/gloas_api.go index 27c0e8aa25a..9c169d1c553 100644 --- a/cl/cltypes/gloas_api.go +++ b/cl/cltypes/gloas_api.go @@ -48,7 +48,7 @@ func (b *GloasBlockContents) DecodeSSZ(buf []byte, version int) error { } func (b *GloasBlockContents) EncodingSizeSSZ() int { - return b.Block.EncodingSizeSSZ() + b.ExecutionPayloadEnvelope.EncodingSizeSSZ() + b.KZGProofs.EncodingSizeSSZ() + b.Blobs.EncodingSizeSSZ() + return 4*4 + b.Block.EncodingSizeSSZ() + b.ExecutionPayloadEnvelope.EncodingSizeSSZ() + b.KZGProofs.EncodingSizeSSZ() + b.Blobs.EncodingSizeSSZ() } func (b *GloasBlockContents) Static() bool { return false } @@ -84,7 +84,7 @@ func (c *SignedExecutionPayloadEnvelopeContents) DecodeSSZStrict(buf []byte, ver } func (c *SignedExecutionPayloadEnvelopeContents) EncodingSizeSSZ() int { - return c.SignedExecutionPayloadEnvelope.EncodingSizeSSZ() + c.KZGProofs.EncodingSizeSSZ() + c.Blobs.EncodingSizeSSZ() + return 3*4 + c.SignedExecutionPayloadEnvelope.EncodingSizeSSZ() + c.KZGProofs.EncodingSizeSSZ() + c.Blobs.EncodingSizeSSZ() } func (c *SignedExecutionPayloadEnvelopeContents) Static() bool { return false } diff --git a/cl/cltypes/gloas_api_test.go b/cl/cltypes/gloas_api_test.go index 044a5fbd5e1..6db6a523ab9 100644 --- a/cl/cltypes/gloas_api_test.go +++ b/cl/cltypes/gloas_api_test.go @@ -34,6 +34,7 @@ func TestGloasBlockContentsSSZRoundTrip(t *testing.T) { encoded, err := contents.EncodeSSZ(nil) require.NoError(t, err) + require.Equal(t, contents.Block.EncodingSizeSSZ()+contents.ExecutionPayloadEnvelope.EncodingSizeSSZ()+contents.KZGProofs.EncodingSizeSSZ()+contents.Blobs.EncodingSizeSSZ()+4*4, contents.EncodingSizeSSZ()) decoded := NewGloasBlockContents(&cfg, 64) require.NoError(t, decoded.DecodeSSZ(encoded, int(clparams.GloasVersion))) @@ -49,6 +50,7 @@ func TestSignedExecutionPayloadEnvelopeContentsSSZRoundTrip(t *testing.T) { encoded, err := contents.EncodeSSZ(nil) require.NoError(t, err) + require.Equal(t, contents.SignedExecutionPayloadEnvelope.EncodingSizeSSZ()+contents.KZGProofs.EncodingSizeSSZ()+contents.Blobs.EncodingSizeSSZ()+3*4, contents.EncodingSizeSSZ()) decoded := NewSignedExecutionPayloadEnvelopeContents(&cfg, 64) require.NoError(t, decoded.DecodeSSZ(encoded, int(clparams.GloasVersion))) diff --git a/cl/phase1/network/services/execution_payload_service.go b/cl/phase1/network/services/execution_payload_service.go index 4a155bd7ad5..ffc68c9401b 100644 --- a/cl/phase1/network/services/execution_payload_service.go +++ b/cl/phase1/network/services/execution_payload_service.go @@ -266,7 +266,10 @@ func (s *executionPayloadService) emitFullHeadUpdate(block *cltypes.SignedBeacon } s.emitters.WithHeadEventLock(func() { currentHeadRoot, currentHeadSlot, err := s.forkchoiceStore.GetHead(nil) - if err != nil || currentHeadRoot != headRoot || currentHeadSlot != headSlot { + currentPayloadStatus := beaconevents.PayloadStatusName(s.forkchoiceStore.GetHeadPayloadStatus()) + currentOptimistic := s.forkchoiceStore.IsRootOptimistic(currentHeadRoot) + if err != nil || currentHeadRoot != headRoot || currentHeadSlot != headSlot || + currentPayloadStatus != headEvent.Data.PayloadStatus || currentOptimistic != headEvent.Data.ExecutionOptimistic { return } s.emitters.State().SendHeadV2(headEvent) diff --git a/cl/phase1/network/services/execution_payload_service_test.go b/cl/phase1/network/services/execution_payload_service_test.go index bbcd6c69bb9..6c4d90c052e 100644 --- a/cl/phase1/network/services/execution_payload_service_test.go +++ b/cl/phase1/network/services/execution_payload_service_test.go @@ -216,6 +216,38 @@ func TestExecutionPayloadServiceDoesNotEmitStaleHeadV2AfterReorg(t *testing.T) { } } +func TestExecutionPayloadServiceDoesNotEmitFullHeadV2AfterStatusChanges(t *testing.T) { + cfg := &clparams.MainnetBeaconConfig + forkchoiceMock := mock_services.NewForkChoiceStorageMock(t) + emitter := beaconevents.NewEventEmitter() + service := NewExecutionPayloadService(t.Context(), forkchoiceMock, cfg, emitter) + stateEvents := make(chan *beaconevents.EventStream, 1) + stateSubscription := emitter.State().Subscribe(stateEvents) + defer stateSubscription.Unsubscribe() + + blockRoot := common.Hash{1} + forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100, StateRoot: common.Hash{2}}} + headState := state.New(cfg) + headState.SetVersion(clparams.GloasVersion) + require.NoError(t, headState.SetSlot(100)) + require.NoError(t, headState.SetBlockRootAt(63, common.Hash{3})) + require.NoError(t, headState.SetBlockRootAt(95, common.Hash{4})) + forkchoiceMock.GetStateAtBlockRootFn = func(common.Hash, bool) (*state.CachingBeaconState, error) { + forkchoiceMock.HeadPayloadStatusVal = cltypes.PayloadStatusEmpty + return headState, nil + } + forkchoiceMock.HeadVal = blockRoot + forkchoiceMock.HeadSlotVal = 100 + forkchoiceMock.HeadPayloadStatusVal = cltypes.PayloadStatusFull + + require.NoError(t, service.ProcessMessage(t.Context(), nil, newTestSignedEnvelope(100, blockRoot, 7))) + select { + case event := <-stateEvents: + t.Fatalf("emitted full head event after status changed: %#v", event) + default: + } +} + func TestExecutionPayloadServiceDoesNotEmitGossipWhenValidationFails(t *testing.T) { cfg := &clparams.MainnetBeaconConfig forkchoiceMock := mock_services.NewForkChoiceStorageMock(t) diff --git a/cl/phase1/stages/forkchoice.go b/cl/phase1/stages/forkchoice.go index bb32ab45944..f2ea9cc9fa4 100644 --- a/cl/phase1/stages/forkchoice.go +++ b/cl/phase1/stages/forkchoice.go @@ -228,7 +228,7 @@ func emitHeadEvent(cfg *Cfg, headSlot uint64, headRoot common.Hash, headState *s return nil } payloadStatus := beaconevents.PayloadStatusName(cfg.forkChoice.GetHeadPayloadStatus()) - executionOptimistic := cfg.forkChoice.IsHeadOptimistic() + executionOptimistic := cfg.forkChoice.IsRootOptimistic(headRoot) headEvent, err := beaconevents.BuildHeadV2Data( cfg.beaconCfg, headState, @@ -243,7 +243,7 @@ func emitHeadEvent(cfg *Cfg, headSlot uint64, headRoot common.Hash, headState *s } return emitHeadEventsIfCurrent(cfg.emitter, headEvent, headSlot, headRoot, stateRoot, func() (common.Hash, uint64, string, bool, error) { root, slot, err := cfg.forkChoice.GetHead(nil) - return root, slot, beaconevents.PayloadStatusName(cfg.forkChoice.GetHeadPayloadStatus()), cfg.forkChoice.IsHeadOptimistic(), err + return root, slot, beaconevents.PayloadStatusName(cfg.forkChoice.GetHeadPayloadStatus()), cfg.forkChoice.IsRootOptimistic(root), err }) } @@ -262,10 +262,10 @@ func emitHeadEventsIfCurrent(emitter *beaconevents.EventEmitter, headEvent *beac Slot: headSlot, Block: headRoot, State: stateRoot, - EpochTransition: true, + EpochTransition: headEvent.Data.EpochTransition, PreviousDutyDependentRoot: headEvent.Data.CurrentEpochDependentRoot, CurrentDutyDependentRoot: headEvent.Data.NextEpochDependentRoot, - ExecutionOptimistic: false, + ExecutionOptimistic: headEvent.Data.ExecutionOptimistic, }) emitter.State().SendHeadV2(headEvent) }) diff --git a/cl/phase1/stages/forkchoice_test.go b/cl/phase1/stages/forkchoice_test.go index 67da0346b44..b013b30e86d 100644 --- a/cl/phase1/stages/forkchoice_test.go +++ b/cl/phase1/stages/forkchoice_test.go @@ -285,3 +285,28 @@ func TestEmitHeadEventsDropsStalePayloadStatus(t *testing.T) { require.NoError(t, err) require.Empty(t, ch) } + +func TestEmitHeadEventsPreservesLegacyHeadFields(t *testing.T) { + emitter := beaconevents.NewEventEmitter() + ch := make(chan *beaconevents.EventStream, 2) + sub := emitter.State().Subscribe(ch) + defer sub.Unsubscribe() + headRoot := common.Hash{1} + headEvent := &beaconevents.HeadV2Data{Data: beaconevents.HeadV2Content{ + PayloadStatus: "pending", + EpochTransition: false, + CurrentEpochDependentRoot: common.Hash{3}, + NextEpochDependentRoot: common.Hash{4}, + ExecutionOptimistic: true, + }} + + err := emitHeadEventsIfCurrent(emitter, headEvent, 10, headRoot, common.Hash{2}, func() (common.Hash, uint64, string, bool, error) { + return headRoot, 10, "pending", true, nil + }) + require.NoError(t, err) + legacy := (<-ch).Data.(*beaconevents.HeadData) + require.False(t, legacy.EpochTransition) + require.True(t, legacy.ExecutionOptimistic) + require.Equal(t, headEvent.Data.CurrentEpochDependentRoot, legacy.PreviousDutyDependentRoot) + require.Equal(t, headEvent.Data.NextEpochDependentRoot, legacy.CurrentDutyDependentRoot) +} From cd8cbe9fa4bf073a8749004c3be7c44268b8b3cb Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 03:55:16 +0800 Subject: [PATCH 25/43] cl/beacon: serve imported blocks before persistence --- cl/beacon/handler/blocks.go | 29 ++++- cl/beacon/handler/blocks_test.go | 182 +++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 cl/beacon/handler/blocks_test.go diff --git a/cl/beacon/handler/blocks.go b/cl/beacon/handler/blocks.go index 531424c1a77..271a46f5b8b 100644 --- a/cl/beacon/handler/blocks.go +++ b/cl/beacon/handler/blocks.go @@ -89,15 +89,14 @@ func (a *ApiHandler) GetEthV1BeaconBlock(w http.ResponseWriter, r *http.Request) return nil, err } - isOptimistic := a.forkchoiceStore.IsRootOptimistic(root) - - blk, err := a.blockReader.ReadBlockByRoot(ctx, tx, root) + blk, err := a.readBlockByRoot(ctx, tx, root) if err != nil { return nil, err } if blk == nil { return nil, beaconhttp.NewEndpointError(http.StatusNotFound, fmt.Errorf("block not found %x", root)) } + isOptimistic := a.forkchoiceStore.IsRootOptimistic(root) // Check if the block is canonical var canonicalRoot common.Hash canonicalRoot, err = beacon_indicies.ReadCanonicalBlockRoot(tx, blk.Block.Slot) @@ -109,6 +108,30 @@ func (a *ApiHandler) GetEthV1BeaconBlock(w http.ResponseWriter, r *http.Request) WithVersion(blk.Version()).WithOptimistic(isOptimistic), nil } +func (a *ApiHandler) readBlockByRoot(ctx context.Context, tx kv.Tx, root common.Hash) (*cltypes.SignedBeaconBlock, error) { + blk, err := a.blockReader.ReadBlockByRoot(ctx, tx, root) + if err != nil || blk != nil { + return blk, err + } + + header, ok := a.forkchoiceStore.GetHeader(root) + if !ok || header == nil { + return nil, nil + } + blk, ok = a.forkchoiceStore.GetBlock(root) + if !ok || blk == nil || blk.Block == nil || blk.Block.Body == nil { + return nil, nil + } + blockRoot, err := blk.Block.HashSSZ() + if err != nil { + return nil, err + } + if blockRoot != root { + return nil, nil + } + return blk, nil +} + func (a *ApiHandler) GetEthV1BlindedBlock(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { ctx := r.Context() tx, err := a.indiciesDB.BeginRo(ctx) diff --git a/cl/beacon/handler/blocks_test.go b/cl/beacon/handler/blocks_test.go new file mode 100644 index 00000000000..99d10aa1b12 --- /dev/null +++ b/cl/beacon/handler/blocks_test.go @@ -0,0 +1,182 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package handler + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cl/clparams" + "github.com/erigontech/erigon/cl/cltypes" + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/snapshotsync/freezeblocks" +) + +type gatedBeaconBlockReader struct { + freezeblocks.BeaconSnapshotReader + entered chan struct{} + release chan struct{} +} + +func (r *gatedBeaconBlockReader) ReadBlockByRoot(context.Context, kv.Tx, common.Hash) (*cltypes.SignedBeaconBlock, error) { + close(r.entered) + <-r.release + return nil, nil +} + +func TestGetBeaconBlockByRootUsesImportedBlockBeforePersistence(t *testing.T) { + _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false) + block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock) + block.Block.Slot++ + root, err := block.Block.HashSSZ() + require.NoError(t, err) + forkchoiceStore.Blocks[root] = block + forkchoiceStore.Headers[root] = block.SignedBeaconBlockHeader().Header + + for _, version := range []string{"v1", "v2"} { + t.Run(version, func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/eth/%s/beacon/blocks/0x%x", version, root), http.NoBody) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), fmt.Sprintf(`"slot":"%d"`, block.Block.Slot)) + }) + } +} + +func TestGetBeaconBlockByRootSamplesOptimisticStatusAfterFallback(t *testing.T) { + _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false) + block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock) + block.Block.Slot++ + root, err := block.Block.HashSSZ() + require.NoError(t, err) + reader := &gatedBeaconBlockReader{ + BeaconSnapshotReader: handler.blockReader, + entered: make(chan struct{}), + release: make(chan struct{}), + } + handler.blockReader = reader + response := make(chan *httptest.ResponseRecorder, 1) + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/eth/v2/beacon/blocks/0x%x", root), http.NoBody) + + go func() { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + response <- recorder + }() + + <-reader.entered + forkchoiceStore.IsRootOptimisticVal = true + forkchoiceStore.Blocks[root] = block + forkchoiceStore.Headers[root] = block.SignedBeaconBlockHeader().Header + close(reader.release) + recorder := <-response + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), `"execution_optimistic":true`) +} + +func TestGetBeaconBlockByRootKeepsPersistedBlockFastPath(t *testing.T) { + _, blocks, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false) + block := blocks[len(blocks)-1] + root, err := block.Block.HashSSZ() + require.NoError(t, err) + + for _, version := range []string{"v1", "v2"} { + t.Run(version, func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/eth/%s/beacon/blocks/0x%x", version, root), http.NoBody) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), fmt.Sprintf(`"slot":"%d"`, block.Block.Slot)) + }) + } +} + +func TestGetBeaconBlockByRootRejectsUnvalidatedForkchoiceBlock(t *testing.T) { + _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false) + block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock) + block.Block.Slot++ + root, err := block.Block.HashSSZ() + require.NoError(t, err) + forkchoiceStore.Blocks[root] = block + + for _, version := range []string{"v1", "v2"} { + t.Run(version, func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/eth/%s/beacon/blocks/0x%x", version, root), http.NoBody) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String()) + }) + } +} + +func TestGetBeaconBlockByRootRejectsUnavailableForkchoiceBlock(t *testing.T) { + _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false) + block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock) + block.Block.Slot++ + mismatchedRoot := common.Hash{0xff} + forkchoiceStore.Blocks[mismatchedRoot] = block + forkchoiceStore.Headers[mismatchedRoot] = block.SignedBeaconBlockHeader().Header + headerOnlyRoot := common.Hash{0xdd} + forkchoiceStore.Headers[headerOnlyRoot] = block.SignedBeaconBlockHeader().Header + nilBlockRoot := common.Hash{0xcc} + forkchoiceStore.Blocks[nilBlockRoot] = &cltypes.SignedBeaconBlock{} + forkchoiceStore.Headers[nilBlockRoot] = block.SignedBeaconBlockHeader().Header + nilBodyRoot := common.Hash{0xbb} + nilBodyBlock := block.Clone().(*cltypes.SignedBeaconBlock) + nilBodyBlock.Block.Body = nil + forkchoiceStore.Blocks[nilBodyRoot] = nilBodyBlock + forkchoiceStore.Headers[nilBodyRoot] = block.SignedBeaconBlockHeader().Header + + for _, testCase := range []struct { + name string + root common.Hash + }{ + {name: "unknown", root: common.Hash{0xee}}, + {name: "mismatched", root: mismatchedRoot}, + {name: "header only", root: headerOnlyRoot}, + {name: "nil block", root: nilBlockRoot}, + {name: "nil body", root: nilBodyRoot}, + } { + t.Run(testCase.name, func(t *testing.T) { + for _, version := range []string{"v1", "v2"} { + t.Run(version, func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/eth/%s/beacon/blocks/0x%x", version, testCase.root), http.NoBody) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String()) + }) + } + }) + } +} From 6b8be371f8c478eceb7de0586dc9cc5e845c2218 Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 04:40:24 +0800 Subject: [PATCH 26/43] cl/beacon: expose imported headers before persistence --- cl/beacon/handler/blocks.go | 23 +++-- cl/beacon/handler/headers.go | 34 ++++++- cl/beacon/handler/headers_test.go | 158 ++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 9 deletions(-) diff --git a/cl/beacon/handler/blocks.go b/cl/beacon/handler/blocks.go index 271a46f5b8b..31139758d3f 100644 --- a/cl/beacon/handler/blocks.go +++ b/cl/beacon/handler/blocks.go @@ -113,13 +113,12 @@ func (a *ApiHandler) readBlockByRoot(ctx context.Context, tx kv.Tx, root common. if err != nil || blk != nil { return blk, err } + return a.readLiveBlockByRoot(root) +} - header, ok := a.forkchoiceStore.GetHeader(root) - if !ok || header == nil { - return nil, nil - } - blk, ok = a.forkchoiceStore.GetBlock(root) - if !ok || blk == nil || blk.Block == nil || blk.Block.Body == nil { +func (a *ApiHandler) readLiveBlockByRoot(root common.Hash) (*cltypes.SignedBeaconBlock, error) { + blk, _, ok := a.liveBlockAndHeader(root) + if !ok { return nil, nil } blockRoot, err := blk.Block.HashSSZ() @@ -132,6 +131,18 @@ func (a *ApiHandler) readBlockByRoot(ctx context.Context, tx kv.Tx, root common. return blk, nil } +func (a *ApiHandler) liveBlockAndHeader(root common.Hash) (*cltypes.SignedBeaconBlock, *cltypes.BeaconBlockHeader, bool) { + header, ok := a.forkchoiceStore.GetHeader(root) + if !ok || header == nil { + return nil, nil, false + } + blk, ok := a.forkchoiceStore.GetBlock(root) + if !ok || blk == nil || blk.Block == nil || blk.Block.Body == nil { + return nil, nil, false + } + return blk, header, true +} + func (a *ApiHandler) GetEthV1BlindedBlock(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { ctx := r.Context() tx, err := a.indiciesDB.BeginRo(ctx) diff --git a/cl/beacon/handler/headers.go b/cl/beacon/handler/headers.go index e92f99d7b60..7a04995d92c 100644 --- a/cl/beacon/handler/headers.go +++ b/cl/beacon/handler/headers.go @@ -17,12 +17,15 @@ package handler import ( + "context" "fmt" "net/http" "github.com/erigontech/erigon/cl/beacon/beaconhttp" + "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/persistence/beacon_indicies" "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/db/kv" ) func (a *ApiHandler) getHeaders(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { @@ -116,7 +119,7 @@ func (a *ApiHandler) getHeader(w http.ResponseWriter, r *http.Request) (*beaconh return nil, err } - signedHeader, err := a.blockReader.ReadHeaderByRoot(ctx, tx, root) + signedHeader, live, err := a.readHeaderByRoot(ctx, tx, root) if err != nil { return nil, err } @@ -129,11 +132,36 @@ func (a *ApiHandler) getHeader(w http.ResponseWriter, r *http.Request) (*beaconh if err != nil { return nil, err } + canonical := blockId.Head() || canonicalRoot == root + if live && !canonical && a.enableMemoizedHeadState { + if selectedRoot, _, ok := a.syncedData.SelectedHead(); ok { + canonical = selectedRoot == root || a.forkchoiceStore.Ancestor(selectedRoot, signedHeader.Header.Slot).Root == root + } + } return newBeaconResponse(&headerResponse{ Root: root, - Canonical: blockId.Head() || canonicalRoot == root, + Canonical: canonical, Header: signedHeader, - }).WithFinalized(canonicalRoot == root && signedHeader.Header.Slot <= a.forkchoiceStore.FinalizedSlot()). + }).WithFinalized(canonical && signedHeader.Header.Slot <= a.forkchoiceStore.FinalizedSlot()). WithOptimistic(a.forkchoiceStore.IsRootOptimistic(root)), nil } + +func (a *ApiHandler) readHeaderByRoot(ctx context.Context, tx kv.Tx, root common.Hash) (*cltypes.SignedBeaconBlockHeader, bool, error) { + signedHeader, err := a.blockReader.ReadHeaderByRoot(ctx, tx, root) + if err != nil || signedHeader != nil { + return signedHeader, false, err + } + blk, header, ok := a.liveBlockAndHeader(root) + if !ok { + return nil, false, nil + } + headerRoot, err := header.HashSSZ() + if err != nil { + return nil, false, err + } + if headerRoot != root { + return nil, false, nil + } + return &cltypes.SignedBeaconBlockHeader{Header: header.Copy(), Signature: blk.Signature}, true, nil +} diff --git a/cl/beacon/handler/headers_test.go b/cl/beacon/handler/headers_test.go index d6833cc9b9b..1975a21ada0 100644 --- a/cl/beacon/handler/headers_test.go +++ b/cl/beacon/handler/headers_test.go @@ -19,6 +19,7 @@ package handler import ( "context" "encoding/json" + "errors" "math" "net/http" "net/http/httptest" @@ -27,14 +28,38 @@ import ( "github.com/stretchr/testify/require" antiquarytests "github.com/erigontech/erigon/cl/antiquary/tests" + "github.com/erigontech/erigon/cl/beacon/synced_data" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/persistence/beacon_indicies" + "github.com/erigontech/erigon/cl/phase1/forkchoice" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/snapshotsync/freezeblocks" ) +type gatedBeaconHeaderReader struct { + freezeblocks.BeaconSnapshotReader + entered chan struct{} + release chan struct{} +} + +func (r *gatedBeaconHeaderReader) ReadHeaderByRoot(context.Context, kv.Tx, common.Hash) (*cltypes.SignedBeaconBlockHeader, error) { + close(r.entered) + <-r.release + return nil, nil +} + +type failingBeaconHeaderReader struct { + freezeblocks.BeaconSnapshotReader + err error +} + +func (r *failingBeaconHeaderReader) ReadHeaderByRoot(context.Context, kv.Tx, common.Hash) (*cltypes.SignedBeaconBlockHeader, error) { + return nil, r.err +} + func TestGetHeadersIncludesFinalized(t *testing.T) { db, blocks, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false) blockRoot, err := blocks[0].Block.HashSSZ() @@ -167,3 +192,136 @@ func TestGetHeadHeaderIsCanonicalBeforeDatabasePromotion(t *testing.T) { require.Equal(t, common.Hash(headRoot), body.Data.Root) require.True(t, body.Data.Canonical) } + +func TestGetHeaderByRootUsesImportedBlockBeforePersistence(t *testing.T) { + _, blocks, _, _, _, handler, _, syncData, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock) + block.Block.Slot++ + root, err := block.Block.HashSSZ() + require.NoError(t, err) + forkchoiceStore.Blocks[root] = block + forkchoiceStore.Headers[root] = block.SignedBeaconBlockHeader().Header + handler.enableMemoizedHeadState = true + syncData.(*synced_data.SyncedDataManager).OnSelectedHead(root, block.Block.Slot) + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/headers/0x"+common.Bytes2Hex(root[:]), http.NoBody) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + var response struct { + Data headerResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, common.Hash(root), response.Data.Root) + require.True(t, response.Data.Canonical) + require.Equal(t, block.Block.Slot, response.Data.Header.Header.Slot) +} + +func TestGetHeaderByRootKeepsLiveSideBranchNonCanonical(t *testing.T) { + _, blocks, _, _, _, handler, _, syncData, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock) + block.Block.Slot++ + root, err := block.Block.HashSSZ() + require.NoError(t, err) + forkchoiceStore.Blocks[root] = block + forkchoiceStore.Headers[root] = block.SignedBeaconBlockHeader().Header + handler.enableMemoizedHeadState = true + syncData.(*synced_data.SyncedDataManager).OnSelectedHead(common.Hash{0xaa}, block.Block.Slot+1) + forkchoiceStore.Ancestors[block.Block.Slot] = forkchoice.ForkChoiceNode{Root: common.Hash{0xbb}} + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/headers/0x"+common.Bytes2Hex(root[:]), http.NoBody) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + var response struct { + Data headerResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.False(t, response.Data.Canonical) +} + +func TestGetHeaderByRootRejectsUnavailableForkchoiceBlock(t *testing.T) { + _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false) + block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock) + block.Block.Slot++ + unvalidatedRoot, err := block.Block.HashSSZ() + require.NoError(t, err) + forkchoiceStore.Blocks[unvalidatedRoot] = block + mismatchedRoot := common.Hash{0xff} + forkchoiceStore.Blocks[mismatchedRoot] = block + forkchoiceStore.Headers[mismatchedRoot] = block.SignedBeaconBlockHeader().Header + headerOnlyRoot := common.Hash{0xdd} + forkchoiceStore.Headers[headerOnlyRoot] = block.SignedBeaconBlockHeader().Header + + for _, testCase := range []struct { + name string + root common.Hash + }{ + {name: "unknown", root: common.Hash{0xee}}, + {name: "unvalidated", root: unvalidatedRoot}, + {name: "mismatched", root: mismatchedRoot}, + {name: "header only", root: headerOnlyRoot}, + } { + t.Run(testCase.name, func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/headers/0x"+common.Bytes2Hex(testCase.root[:]), http.NoBody) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String()) + }) + } +} + +func TestGetHeaderByRootPreservesDatabaseReaderError(t *testing.T) { + _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false) + block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock) + block.Block.Slot++ + root, err := block.Block.HashSSZ() + require.NoError(t, err) + forkchoiceStore.Blocks[root] = block + forkchoiceStore.Headers[root] = block.SignedBeaconBlockHeader().Header + databaseErr := errors.New("header database unavailable") + handler.blockReader = &failingBeaconHeaderReader{BeaconSnapshotReader: handler.blockReader, err: databaseErr} + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/headers/0x"+common.Bytes2Hex(root[:]), http.NoBody) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusInternalServerError, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), databaseErr.Error()) +} + +func TestGetHeaderByRootSamplesOptimisticStatusAfterFallback(t *testing.T) { + _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false) + block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock) + block.Block.Slot++ + root, err := block.Block.HashSSZ() + require.NoError(t, err) + reader := &gatedBeaconHeaderReader{ + BeaconSnapshotReader: handler.blockReader, + entered: make(chan struct{}), + release: make(chan struct{}), + } + handler.blockReader = reader + response := make(chan *httptest.ResponseRecorder, 1) + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/headers/0x"+common.Bytes2Hex(root[:]), http.NoBody) + + go func() { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + response <- recorder + }() + + <-reader.entered + forkchoiceStore.IsRootOptimisticVal = true + forkchoiceStore.Blocks[root] = block + forkchoiceStore.Headers[root] = block.SignedBeaconBlockHeader().Header + close(reader.release) + recorder := <-response + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), `"execution_optimistic":true`) +} From 279c1c23c20a4a4e05e4573063e528583c4102cc Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 14:17:02 +0800 Subject: [PATCH 27/43] cl: address Gloas review findings --- cl/beacon/builder/client.go | 66 ++- cl/beacon/builder/client_test.go | 97 +++++ cl/beacon/builder/interface.go | 1 + .../mock_services/builder_client_mock.go | 42 +- cl/beacon/handler/block_production.go | 57 +-- cl/beacon/handler/block_production_test.go | 74 +++- cl/beacon/handler/builder_route_store.go | 9 + cl/beacon/handler/epbs.go | 112 +++-- cl/beacon/handler/epbs_test.go | 385 +++++++++++++++++- cl/beacon/handler/handler.go | 11 +- .../mock_services/forkchoice_mock.go | 4 + cl/phase1/forkchoice/on_execution_payload.go | 3 + .../forkchoice/on_execution_payload_test.go | 6 +- .../services/execution_payload_service.go | 2 +- .../execution_payload_service_test.go | 30 +- .../payload_attestation_service_mock.go | 38 ++ .../services/payload_attestation_service.go | 195 +++++++-- .../payload_attestation_service_test.go | 265 ++++++++++++ cl/phase1/network/services/types.go | 5 +- cl/phase1/stages/forward_sync.go | 41 +- cl/phase1/stages/gloas_payload_test.go | 75 ++++ cmd/caplin/caplin1/run.go | 2 +- 22 files changed, 1370 insertions(+), 150 deletions(-) diff --git a/cl/beacon/builder/client.go b/cl/beacon/builder/client.go index 8a8eca63d47..73fb8d03f48 100644 --- a/cl/beacon/builder/client.go +++ b/cl/beacon/builder/client.go @@ -25,6 +25,7 @@ import ( "io" "net" "net/http" + "net/netip" "net/url" "strconv" "strings" @@ -66,6 +67,7 @@ type builderClient struct { lookupIP func(context.Context, string) ([]net.IPAddr, error) targetPolicy BuilderTargetPolicy transport http.RoundTripper + publicTransport http.RoundTripper admission *semaphore.Weighted admissionOnce sync.Once preferencesAdmission *semaphore.Weighted @@ -99,6 +101,7 @@ func newBlockBuilderClient(baseUrl string, beaconConfig *clparams.BeaconChainCon beaconConfig: beaconConfig, targetPolicy: policy, transport: newPinnedBuilderTransport(nil), + publicTransport: newPinnedBuilderTransport(nil), admission: semaphore.NewWeighted(defaultBuilderCallLimit), preferencesAdmission: semaphore.NewWeighted(defaultPreferenceCallLimit), } @@ -338,6 +341,14 @@ func (b *builderClient) RequestExecutionPayloadBid(ctx context.Context, builderU } func (b *builderClient) SubmitSignedBeaconBlock(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error { + return b.submitSignedBeaconBlock(ctx, builderURL, block, b.targetPolicy, false) +} + +func (b *builderClient) SubmitSignedBeaconBlockPublic(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error { + return b.submitSignedBeaconBlock(ctx, builderURL, block, BuilderTargetPolicy{}, true) +} + +func (b *builderClient) submitSignedBeaconBlock(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock, policy BuilderTargetPolicy, strictPublic bool) error { if block == nil || block.Block == nil || block.Block.Body == nil { return errors.New("nil signed beacon block") } @@ -351,13 +362,17 @@ func (b *builderClient) SubmitSignedBeaconBlock(ctx context.Context, builderURL return err } defer b.builderAdmission().Release(1) - target, err := b.builderEndpoint(requestContext, b.targetPolicy, builderURL, "eth", "v1", "builder", "beacon_blocks") + target, err := b.builderEndpoint(requestContext, policy, builderURL, "eth", "v1", "builder", "beacon_blocks") if err != nil { return err } - response, err := b.builderCall(requestContext, http.MethodPost, target, map[string]string{ + transport := b.transport + if strictPublic { + transport = b.publicTransport + } + response, err := b.builderCallWithTransport(requestContext, http.MethodPost, target, map[string]string{ "Eth-Consensus-Version": block.Version().String(), - }, bytes.NewReader(payload)) + }, bytes.NewReader(payload), transport) if err != nil { return err } @@ -411,7 +426,42 @@ func (b *builderClient) builderEndpoint(ctx context.Context, policy BuilderTarge } func isPublicBuilderIP(ip net.IP) bool { - return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsUnspecified() + addr, ok := netip.AddrFromSlice(ip) + if !ok { + return false + } + addr = addr.Unmap() + if !addr.IsGlobalUnicast() { + return false + } + for _, prefix := range nonPublicBuilderPrefixes { + if prefix.Contains(addr) { + return false + } + } + return true +} + +var nonPublicBuilderPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("127.0.0.0/8"), + netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.31.196.0/24"), netip.MustParsePrefix("192.52.193.0/24"), + netip.MustParsePrefix("192.88.99.0/24"), netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("192.175.48.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("224.0.0.0/4"), + netip.MustParsePrefix("240.0.0.0/4"), netip.MustParsePrefix("::/128"), + netip.MustParsePrefix("::1/128"), netip.MustParsePrefix("64:ff9b::/96"), + netip.MustParsePrefix("64:ff9b:1::/48"), netip.MustParsePrefix("100:0:0:1::/64"), + netip.MustParsePrefix("100::/64"), + netip.MustParsePrefix("2001::/23"), netip.MustParsePrefix("2001:2::/48"), + netip.MustParsePrefix("2001:db8::/32"), netip.MustParsePrefix("2002::/16"), + netip.MustParsePrefix("3fff::/20"), netip.MustParsePrefix("5f00::/16"), + netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fec0::/10"), + netip.MustParsePrefix("fe80::/10"), + netip.MustParsePrefix("ff00::/8"), } func isAllowedBuilderIP(ip net.IP, policy BuilderTargetPolicy) bool { @@ -428,6 +478,10 @@ type builderHTTPResponse struct { } func (b *builderClient) builderCall(ctx context.Context, method string, target builderTarget, headers map[string]string, body io.Reader) (*builderHTTPResponse, error) { + return b.builderCallWithTransport(ctx, method, target, headers, body, b.transport) +} + +func (b *builderClient) builderCallWithTransport(ctx context.Context, method string, target builderTarget, headers map[string]string, body io.Reader, transport http.RoundTripper) (*builderHTTPResponse, error) { var attemptTimeout time.Duration if deadline, ok := ctx.Deadline(); ok && len(target.ips) > 1 { attemptTimeout = time.Until(deadline) / time.Duration(len(target.ips)) @@ -448,8 +502,8 @@ func (b *builderClient) builderCall(ctx context.Context, method string, target b return nil, errors.New("nil builder HTTP client") } client := *b.httpClient - if b.transport != nil { - client.Transport = b.transport + if transport != nil { + client.Transport = transport } client.CheckRedirect = func(*http.Request, []*http.Request) error { return errors.New("builder redirects are not allowed") diff --git a/cl/beacon/builder/client_test.go b/cl/beacon/builder/client_test.go index 2735df4d7f7..7e015d5b020 100644 --- a/cl/beacon/builder/client_test.go +++ b/cl/beacon/builder/client_test.go @@ -739,6 +739,103 @@ func TestSubmitSignedBeaconBlock(t *testing.T) { require.NoError(t, client.SubmitSignedBeaconBlock(context.Background(), "https://builder.example", block)) } +func TestSubmitSignedBeaconBlockPublicRejectsPrivateTargetWithPrivateConfigured(t *testing.T) { + for _, addresses := range [][]net.IPAddr{ + {{IP: net.ParseIP("127.0.0.1")}}, + {{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("127.0.0.1")}}, + } { + client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{AllowPrivate: true}) + client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { + return addresses, nil + } + err := client.SubmitSignedBeaconBlockPublic(t.Context(), "http://builder.example", cltypes.NewSignedBeaconBlock(mockBeaconConfig, clparams.GloasVersion)) + require.ErrorContains(t, err, "disallowed address") + } +} + +func TestIsPublicBuilderIPRejectsSpecialPurposeRanges(t *testing.T) { + for _, value := range []string{ + "10.0.0.1", "100.64.0.1", "127.0.0.1", "169.254.1.1", "192.0.2.1", + "198.18.0.1", "198.51.100.1", "203.0.113.1", "240.0.0.1", + "::1", "64:ff9b::1", "64:ff9b:1::1", "100::1", "100:0:0:1::1", + "2001:db8::1", "fc00::1", "fec0::1", "fe80::1", + "::ffff:100.64.0.1", + } { + require.False(t, isPublicBuilderIP(net.ParseIP(value)), value) + } + for _, value := range []string{"8.8.8.8", "93.184.216.34", "2001:4860:4860::8888"} { + require.True(t, isPublicBuilderIP(net.ParseIP(value)), value) + } +} + +func TestSubmitSignedBeaconBlockPublicRejectsNewSpecialPurposeMixedAnswers(t *testing.T) { + for _, special := range []string{"fec0::1", "64:ff9b::1", "100:0:0:1::1"} { + t.Run(special, func(t *testing.T) { + client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{}) + client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("2001:4860:4860::8888")}, {IP: net.ParseIP(special)}}, nil + } + err := client.SubmitSignedBeaconBlockPublic(t.Context(), "http://builder.example", cltypes.NewSignedBeaconBlock(mockBeaconConfig, clparams.GloasVersion)) + require.ErrorContains(t, err, "disallowed address") + }) + } +} + +func TestSubmitSignedBeaconBlockPublicDoesNotReuseTrustedPrivateKeepalive(t *testing.T) { + client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{AllowPrivate: true}) + resolved := net.ParseIP("127.0.0.1") + client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: resolved}}, nil + } + privateRequests := make(chan struct{}, 2) + client.transport = newPinnedBuilderTransport(func(_ context.Context, _, _ string) (net.Conn, error) { + clientConn, serverConn := net.Pipe() + go func() { + defer serverConn.Close() + reader := bufio.NewReader(serverConn) + for { + request, err := http.ReadRequest(reader) + if err != nil { + return + } + request.Body.Close() + privateRequests <- struct{}{} + _, _ = serverConn.Write([]byte("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n")) + } + }() + return clientConn, nil + }) + publicDialed := make(chan string, 1) + client.publicTransport = newPinnedBuilderTransport(func(_ context.Context, _, address string) (net.Conn, error) { + publicDialed <- address + clientConn, serverConn := net.Pipe() + go func() { + defer serverConn.Close() + request, err := http.ReadRequest(bufio.NewReader(serverConn)) + if err == nil { + request.Body.Close() + _, _ = serverConn.Write([]byte("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) + } + }() + return clientConn, nil + }) + t.Cleanup(func() { + client.transport.(*http.Transport).CloseIdleConnections() + client.publicTransport.(*http.Transport).CloseIdleConnections() + }) + block := cltypes.NewSignedBeaconBlock(mockBeaconConfig, clparams.GloasVersion) + require.NoError(t, client.SubmitSignedBeaconBlock(t.Context(), "http://builder.example:18550", block)) + <-privateRequests + resolved = net.ParseIP("93.184.216.34") + require.NoError(t, client.SubmitSignedBeaconBlockPublic(t.Context(), "http://builder.example:18550", block)) + require.Equal(t, "93.184.216.34:18550", <-publicDialed) + select { + case <-privateRequests: + t.Fatal("strict-public request reused trusted private keepalive") + default: + } +} + func TestSubmitSignedBeaconBlockHasHotPathDeadline(t *testing.T) { block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) { diff --git a/cl/beacon/builder/interface.go b/cl/beacon/builder/interface.go index f751905c3f0..14ed897b754 100644 --- a/cl/beacon/builder/interface.go +++ b/cl/beacon/builder/interface.go @@ -33,5 +33,6 @@ type BuilderClient interface { SubmitBuilderPreferences(ctx context.Context, builderURL string, proposerPubkey common.Bytes48, request *cltypes.BuilderPreferencesRequest) error RequestExecutionPayloadBid(ctx context.Context, builderURL string, slot uint64, parentHash, parentRoot common.Hash, proposerPubkey common.Bytes48, auth *cltypes.SignedBuilderRequestAuth, timeout time.Duration) (*cltypes.SignedExecutionPayloadBid, error) SubmitSignedBeaconBlock(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error + SubmitSignedBeaconBlockPublic(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error GetStatus(ctx context.Context) error } diff --git a/cl/beacon/builder/mock_services/builder_client_mock.go b/cl/beacon/builder/mock_services/builder_client_mock.go index 7e51d900a15..d5a61a8be62 100644 --- a/cl/beacon/builder/mock_services/builder_client_mock.go +++ b/cl/beacon/builder/mock_services/builder_client_mock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: ./cl/beacon/builder/interface.go +// Source: github.com/erigontech/erigon/cl/beacon/builder (interfaces: BuilderClient) // // Generated by this command: // -// mockgen -source=./cl/beacon/builder/interface.go -typed=true -destination=./cl/beacon/builder/mock_services/builder_client_mock.go -package=mock_services +// mockgen -typed=true -destination=./mock_services/builder_client_mock.go -package=mock_services . BuilderClient // // Package mock_services is a generated GoMock package. @@ -315,3 +315,41 @@ func (c *MockBuilderClientSubmitSignedBeaconBlockCall) DoAndReturn(f func(contex c.Call = c.Call.DoAndReturn(f) return c } + +// SubmitSignedBeaconBlockPublic mocks base method. +func (m *MockBuilderClient) SubmitSignedBeaconBlockPublic(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitSignedBeaconBlockPublic", ctx, builderURL, block) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitSignedBeaconBlockPublic indicates an expected call of SubmitSignedBeaconBlockPublic. +func (mr *MockBuilderClientMockRecorder) SubmitSignedBeaconBlockPublic(ctx, builderURL, block any) *MockBuilderClientSubmitSignedBeaconBlockPublicCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitSignedBeaconBlockPublic", reflect.TypeOf((*MockBuilderClient)(nil).SubmitSignedBeaconBlockPublic), ctx, builderURL, block) + return &MockBuilderClientSubmitSignedBeaconBlockPublicCall{Call: call} +} + +// MockBuilderClientSubmitSignedBeaconBlockPublicCall wrap *gomock.Call +type MockBuilderClientSubmitSignedBeaconBlockPublicCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockBuilderClientSubmitSignedBeaconBlockPublicCall) Return(arg0 error) *MockBuilderClientSubmitSignedBeaconBlockPublicCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockBuilderClientSubmitSignedBeaconBlockPublicCall) Do(f func(context.Context, string, *cltypes.SignedBeaconBlock) error) *MockBuilderClientSubmitSignedBeaconBlockPublicCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockBuilderClientSubmitSignedBeaconBlockPublicCall) DoAndReturn(f func(context.Context, string, *cltypes.SignedBeaconBlock) error) *MockBuilderClientSubmitSignedBeaconBlockPublicCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index c08fde1d569..75e6ed86c81 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -1655,11 +1655,7 @@ func (a *ApiHandler) produceBeaconBody( // BlobKzgCommitments are already populated during bundle processing above beaconBody.SignedExecutionPayloadBid.Signature = common.Bytes96(bls.InfiniteSignature) - // Cache the execution payload and requests so broadcastBlock can construct - // the SignedExecutionPayloadEnvelope when the validator publishes the signed - // block. The envelope needs the beacon block root (only available after the - // block is fully assembled), so we defer envelope construction to broadcast time. - // [New in Gloas:EIP7732] + // Retain the payload until the validator publishes its signed envelope. cachedExecReqs := gloasExecRequests if cachedExecReqs == nil { cachedExecReqs = cltypes.NewExecutionRequestsWithVersion(a.beaconChainCfg, clparams.GloasVersion) @@ -1870,19 +1866,28 @@ func (a *ApiHandler) forwardPublishedBlockToBuilder(builderURL string, block *cl if err != nil { return } - if !a.builderRoutes.ClaimOrAdd(root, builderURL) { + trustedRoute := a.builderRoutes.Claim(root, builderURL) + if !trustedRoute && !a.builderRoutes.ClaimOrAdd(root, builderURL) { return } go func() { var err error for range 2 { - err = a.builderClient.SubmitSignedBeaconBlock(context.Background(), builderURL, block) + if trustedRoute { + err = a.builderClient.SubmitSignedBeaconBlock(context.Background(), builderURL, block) + } else { + err = a.builderClient.SubmitSignedBeaconBlockPublic(context.Background(), builderURL, block) + } if err == nil { a.builderRoutes.Complete(root, builderURL, true) return } } - a.builderRoutes.Complete(root, builderURL, false) + if trustedRoute { + a.builderRoutes.Complete(root, builderURL, false) + } else { + a.builderRoutes.Discard(root, builderURL) + } a.logger.Warn("Failed to forward signed block to builder", "err", err) }() } @@ -2375,13 +2380,9 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac } } - // [New in Gloas:EIP7732] For self-built blocks, construct and broadcast the - // SignedExecutionPayloadEnvelope so the block can transition from PENDING to FULL. - // If the validator client provided a signed envelope, use it directly (real BLS signature). - // Otherwise fall back to constructing one from the cache (legacy/fallback path). if blk.Version() >= clparams.GloasVersion { - if err := a.applySelfBuildEnvelope(ctx, blk); err != nil { - a.logger.Error("Failed to broadcast self-build execution payload envelope", "err", err) + if err := a.validateSelfBuildPayloadAvailable(blk); err != nil { + a.logger.Error("Self-build payload unavailable for validator envelope", "err", err) } } @@ -2452,7 +2453,7 @@ func collectPublishedPayloadData( return cellsAndProofs, false, nil } -func (a *ApiHandler) applySelfBuildEnvelope(ctx context.Context, blk *cltypes.SignedBeaconBlock) error { +func (a *ApiHandler) validateSelfBuildPayloadAvailable(blk *cltypes.SignedBeaconBlock) error { bid := blk.Block.Body.GetSignedExecutionPayloadBid() if bid == nil || bid.Message == nil { return nil // no bid in block, nothing to do @@ -2461,34 +2462,10 @@ func (a *ApiHandler) applySelfBuildEnvelope(ctx context.Context, blk *cltypes.Si return nil // not a self-build block; builder will broadcast the envelope } - blockRoot, err := blk.Block.HashSSZ() - if err != nil { - return fmt.Errorf("failed to compute block root: %w", err) - } - - cached, ok := a.selfBuildPayloads.Get(bid.Message.BlockHash) + _, ok := a.selfBuildPayloads.Get(bid.Message.BlockHash) if !ok { return fmt.Errorf("self-build payload not found in cache for block hash %v", bid.Message.BlockHash) } - execReqs := cached.ExecutionRequests - if execReqs == nil { - execReqs = cltypes.NewExecutionRequestsWithVersion(a.beaconChainCfg, clparams.GloasVersion) - } - signedEnvelope := &cltypes.SignedExecutionPayloadEnvelope{ - Message: &cltypes.ExecutionPayloadEnvelope{ - Payload: cached.Payload, - ExecutionRequests: execReqs, - BuilderIndex: clparams.BuilderIndexSelfBuild, - BeaconBlockRoot: blockRoot, - ParentBeaconBlockRoot: blk.Block.ParentRoot, - }, - Signature: common.Bytes96(bls.InfiniteSignature), - } - - a.selfBuildPayloads.Remove(bid.Message.BlockHash) - if err := a.forkchoiceStore.ApplyLocalSelfBuildEnvelope(ctx, signedEnvelope); err != nil { - a.logger.Debug("Self-build envelope queued for pending processing", "err", err, "blockRoot", blockRoot) - } return nil } diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index 66a598e6d8a..aa9439e9455 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -669,7 +669,7 @@ func TestPostEthV2BeaconBlocksForwardsUnboundBuilderRoute(t *testing.T) { builderURL := "https://builder.example" forwarded := make(chan struct{}) client := builder_mock.NewMockBuilderClient(gomock.NewController(t)) - client.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, gomock.Any()).DoAndReturn( + client.EXPECT().SubmitSignedBeaconBlockPublic(gomock.Any(), builderURL, gomock.Any()).DoAndReturn( func(context.Context, string, *cltypes.SignedBeaconBlock) error { close(forwarded) return nil @@ -789,6 +789,78 @@ func TestForwardPublishedBlockToBuilderRetriesAfterFailure(t *testing.T) { } } +func TestForwardPublishedBlockUnboundRetryRemainsPublicOnly(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 0 + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + builderURL := "https://builder.example" + client := builder_mock.NewMockBuilderClient(gomock.NewController(t)) + failedAttempts := make(chan struct{}) + succeeded := make(chan struct{}) + gomock.InOrder( + client.EXPECT().SubmitSignedBeaconBlockPublic(gomock.Any(), builderURL, block).Return(errors.New("unavailable")), + client.EXPECT().SubmitSignedBeaconBlockPublic(gomock.Any(), builderURL, block).DoAndReturn( + func(context.Context, string, *cltypes.SignedBeaconBlock) error { + close(failedAttempts) + return errors.New("unavailable") + }, + ), + client.EXPECT().SubmitSignedBeaconBlockPublic(gomock.Any(), builderURL, block).DoAndReturn( + func(context.Context, string, *cltypes.SignedBeaconBlock) error { + close(succeeded) + return nil + }, + ), + ) + handler.builderClient = client + + handler.forwardPublishedBlockToBuilder(builderURL, block) + select { + case <-failedAttempts: + case <-time.After(time.Second): + t.Fatal("unbound route attempts did not fail") + } + deadline := time.Now().Add(time.Second) + for { + handler.builderRoutes.mu.Lock() + _, exists := handler.builderRoutes.routes[builderRouteKey{root: root, url: builderURL}] + handler.builderRoutes.mu.Unlock() + if !exists { + break + } + if time.Now().After(deadline) { + t.Fatal("unbound route was not discarded after failure") + } + time.Sleep(time.Millisecond) + } + handler.forwardPublishedBlockToBuilder(builderURL, block) + select { + case <-succeeded: + case <-time.After(time.Second): + t.Fatal("unbound public retry did not succeed") + } +} + +func TestValidateSelfBuildPayloadAvailablePreservesUnsignedPayloadForValidator(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 0 + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + payloadHash := common.HexToHash("0x1234") + block.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = clparams.BuilderIndexSelfBuild + block.Block.Body.SignedExecutionPayloadBid.Message.BlockHash = payloadHash + cacheEntry := &selfBuildPayload{Payload: cltypes.NewEth1Block(clparams.GloasVersion, handler.beaconChainCfg)} + cacheEntry.Payload.BlockHash = payloadHash + handler.selfBuildPayloads.Add(payloadHash, cacheEntry) + + require.NoError(t, handler.validateSelfBuildPayloadAvailable(block)) + retained, ok := handler.selfBuildPayloads.Get(payloadHash) + require.True(t, ok) + require.Same(t, cacheEntry, retained) + require.Empty(t, fcu.Envelopes) +} + func TestParseBlockPublishingValidationRejectsUnknownV2Value(t *testing.T) { req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks?broadcast_validation=fast", nil) _, err := (&ApiHandler{}).parseBlockPublishingValidation(req, 2) diff --git a/cl/beacon/handler/builder_route_store.go b/cl/beacon/handler/builder_route_store.go index 086d51f3430..b1f1e40ae04 100644 --- a/cl/beacon/handler/builder_route_store.go +++ b/cl/beacon/handler/builder_route_store.go @@ -165,6 +165,15 @@ func (s *builderRouteStore) Complete(root common.Hash, url string, delivered boo } } +func (s *builderRouteStore) Discard(root common.Hash, url string) { + s.mu.Lock() + defer s.mu.Unlock() + key := builderRouteKey{root: root, url: url} + if route, ok := s.routes[key]; ok && route.state == builderRouteInFlight { + delete(s.routes, key) + } +} + func (s *builderRouteStore) pruneExpired(now time.Time) { for key, route := range s.routes { if route.state != builderRouteInFlight && !now.Before(route.expiresAt) { diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index f6031c55116..ab09949a1bc 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -497,7 +497,7 @@ func (a *ApiHandler) PostEthV1BeaconPoolPayloadAttestations(w http.ResponseWrite msgSize := (&cltypes.PayloadAttestationMessage{ Data: new(cltypes.PayloadAttestationData), }).EncodingSizeSSZ() - if len(octets) == 0 || len(octets)%msgSize != 0 { + if len(octets)%msgSize != 0 { beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("SSZ body length %d is not a multiple of PayloadAttestationMessage size %d", len(octets), msgSize)).WriteTo(w) return @@ -546,38 +546,35 @@ func (a *ApiHandler) PostEthV1BeaconPoolPayloadAttestations(w http.ResponseWrite // Validate via PayloadAttestationService (handles dedup, clock disparity, pending queue, // and delegates to forkchoice.OnPayloadAttestationMessage for signature + PTC checks) - if a.payloadAttestationService != nil { - if err := a.payloadAttestationService.ProcessMessage(r.Context(), nil, msg); err != nil { - if errors.Is(err, clservices.ErrAttestationQueued) { - continue - } - failures = append(failures, poolingFailure{ - Index: i, - Message: err.Error(), - }) - continue - } + if a.payloadAttestationService == nil { + failures = append(failures, poolingFailure{Index: i, Message: "payload attestation validation unavailable"}) + continue } - - // Store in pool for GET endpoint serving - if a.epbsPool != nil { - a.epbsPool.PayloadAttestations.Add(pool.PayloadAttestationKey{ - Slot: msg.Data.Slot, - ValidatorIndex: msg.ValidatorIndex, - }, msg) + encodedSSZ, err := msg.EncodeSSZ(nil) + if err != nil { + beaconhttp.NewEndpointError(http.StatusInternalServerError, err).WriteTo(w) + return } - - // Broadcast to gossip - if a.sentinel != nil { - encodedSSZ, err := msg.EncodeSSZ(nil) - if err != nil { + publishFailed := false + if err := a.payloadAttestationService.ProcessRESTMessage(r.Context(), msg, func() error { + err := a.publishGossip(r.Context(), gossip.TopicNamePayloadAttestation, encodedSSZ) + publishFailed = err != nil + return err + }); err != nil { + if publishFailed { beaconhttp.NewEndpointError(http.StatusInternalServerError, err).WriteTo(w) return } - if err := a.gossipManager.Publish(r.Context(), gossip.TopicNamePayloadAttestation, encodedSSZ); err != nil { - a.logger.Debug("[Beacon REST] failed to publish payload attestation to gossip", "err", err) + if errors.Is(err, clservices.ErrAttestationDuplicate) { + continue } + failures = append(failures, poolingFailure{ + Index: i, + Message: err.Error(), + }) + continue } + } if len(failures) > 0 { @@ -800,7 +797,9 @@ func (a *ApiHandler) GetEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrite } slot := block.Block.Slot epoch := slot / a.beaconChainCfg.SlotsPerEpoch - isFinalized := slot <= a.forkchoiceStore.FinalizedSlot() + finalized := a.forkchoiceStore.FinalizedCheckpoint() + finalizedSlot := finalized.Epoch * a.beaconChainCfg.SlotsPerEpoch + isFinalized := slot <= finalizedSlot && a.forkchoiceStore.Ancestor(finalized.Root, slot).Root == blockRoot return newBeaconResponse(envelope). WithVersion(a.beaconChainCfg.GetCurrentStateVersion(epoch)). WithOptimistic(a.forkchoiceStore.IsRootOptimistic(blockRoot)). @@ -892,6 +891,7 @@ func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit status = http.StatusAccepted } gossipValidated := false + emitGossipEvent := false if err := a.forkchoiceStore.OnExecutionPayload(r.Context(), signedEnvelope, canonical, true); err != nil { if canonical && !blobDataIncluded && errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) { beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) @@ -902,13 +902,28 @@ func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit return } switch { - case errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable): + case errors.Is(err, forkchoice.ErrIgnore): + persisted, _ := a.forkchoiceStore.ReadEnvelopeFromDisk(signedEnvelope.Message.BeaconBlockRoot) + if !signedExecutionPayloadEnvelopesEqual(persisted, signedEnvelope) { + if canonical { + beaconhttp.NewEndpointError(http.StatusServiceUnavailable, err).WriteTo(w) + return + } + status = http.StatusAccepted + break + } a.logger.Debug("[Beacon REST] OnExecutionPayload queued or ignored", "err", err) status = http.StatusAccepted - gossipValidated = errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) + gossipValidated = true + case errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable): + a.logger.Debug("[Beacon REST] OnExecutionPayload queued or ignored", "err", err) + status = http.StatusAccepted + gossipValidated = true + emitGossipEvent = true case canonical && validation == BlockPublishingValidationGossip: status = http.StatusAccepted gossipValidated = true + emitGossipEvent = true case canonical: beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) return @@ -918,8 +933,16 @@ func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit } } else { gossipValidated = true + emitGossipEvent = true } - if a.emitters != nil && gossipValidated && signedEnvelope.Message.Payload != nil { + if gossipValidated && (canonical || a.sentinel != nil) && validation == BlockPublishingValidationConsensusAndEquivocation { + block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot) + if !ok || block == nil || block.Block == nil || a.forkchoiceStore.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, signedEnvelope.Message.BeaconBlockRoot) { + beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("execution payload envelope block has an equivocation")).WriteTo(w) + return + } + } + if emitGossipEvent && (canonical || a.sentinel != nil) && a.emitters != nil && signedEnvelope.Message.Payload != nil { block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot) if ok && block != nil && block.Block != nil { a.emitters.Operation().SendExecutionPayloadGossip(&beaconevents.ExecutionPayloadGossipData{ @@ -943,14 +966,7 @@ func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit } } - if canonical || a.sentinel != nil { - if validation == BlockPublishingValidationConsensusAndEquivocation { - block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot) - if !ok || block == nil || block.Block == nil || a.forkchoiceStore.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, signedEnvelope.Message.BeaconBlockRoot) { - beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("execution payload envelope block has an equivocation")).WriteTo(w) - return - } - } + if gossipValidated && (canonical || a.sentinel != nil) { encodedSSZ, err := signedEnvelope.EncodeSSZ(nil) if err != nil { beaconhttp.NewEndpointError(http.StatusInternalServerError, err).WriteTo(w) @@ -965,22 +981,38 @@ func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit w.WriteHeader(status) } +func signedExecutionPayloadEnvelopesEqual(left, right *cltypes.SignedExecutionPayloadEnvelope) bool { + if left == nil || right == nil { + return false + } + leftSSZ, leftErr := left.EncodeSSZ(nil) + rightSSZ, rightErr := right.EncodeSSZ(nil) + return leftErr == nil && rightErr == nil && bytes.Equal(leftSSZ, rightSSZ) +} + func (a *ApiHandler) emitFullHeadV2(block *cltypes.SignedBeaconBlock, blockRoot common.Hash) { headRoot, headSlot, err := a.forkchoiceStore.GetHead(nil) if err != nil || headRoot != blockRoot || a.beaconChainCfg.SlotsPerEpoch == 0 { return } + payloadStatus := a.forkchoiceStore.GetHeadPayloadStatus() + if payloadStatus != cltypes.PayloadStatusFull { + return + } + optimistic := a.forkchoiceStore.IsRootOptimistic(blockRoot) headState, err := a.forkchoiceStore.GetStateAtBlockRoot(blockRoot, true) if err != nil || headState == nil { return } - event, err := beaconevents.BuildHeadV2Data(a.beaconChainCfg, headState, headSlot, headRoot, block.Block.StateRoot, "full", a.forkchoiceStore.IsRootOptimistic(blockRoot)) + event, err := beaconevents.BuildHeadV2Data(a.beaconChainCfg, headState, headSlot, headRoot, block.Block.StateRoot, "full", optimistic) if err != nil { return } a.emitters.WithHeadEventLock(func() { currentRoot, currentSlot, err := a.forkchoiceStore.GetHead(nil) - if err != nil || currentRoot != headRoot || currentSlot != headSlot { + if err != nil || currentRoot != headRoot || currentSlot != headSlot || + a.forkchoiceStore.GetHeadPayloadStatus() != payloadStatus || + a.forkchoiceStore.IsRootOptimistic(currentRoot) != optimistic { return } a.emitters.State().SendHeadV2(event) diff --git a/cl/beacon/handler/epbs_test.go b/cl/beacon/handler/epbs_test.go index faa25e72f37..a62bf2145b6 100644 --- a/cl/beacon/handler/epbs_test.go +++ b/cl/beacon/handler/epbs_test.go @@ -25,6 +25,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -38,6 +39,8 @@ import ( "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" "github.com/erigontech/erigon/cl/gossip" + "github.com/erigontech/erigon/cl/persistence/beacon_indicies" + "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/phase1/forkchoice" gossip_mock "github.com/erigontech/erigon/cl/phase1/network/gossip/mock_services" "github.com/erigontech/erigon/cl/phase1/network/services" @@ -46,6 +49,7 @@ import ( "github.com/erigontech/erigon/cl/utils/bls" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv" ) func TestGetPayloadAttestationDataAcceptsCanonicalSlotQuery(t *testing.T) { @@ -244,14 +248,38 @@ func TestPostPayloadAttestationsRejectsOversizedSSZ(t *testing.T) { require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) } +func TestPostPayloadAttestationsAcceptsEmptySSZ(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", http.NoBody) + request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) +} + func TestPostPayloadAttestationsAcceptsMoreThanBlockAggregateLimitSSZ(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + ctrl := gomock.NewController(t) + attestationService := mock_services.NewMockPayloadAttestationService(ctrl) + handler.payloadAttestationService = attestationService + gossipManager := gossip_mock.NewMockGossip(ctrl) + handler.gossipManager = gossipManager msg := &cltypes.PayloadAttestationMessage{ Data: new(cltypes.PayloadAttestationData), } encoded, err := msg.EncodeSSZ(nil) require.NoError(t, err) body := strings.Repeat(string(encoded), int(handler.beaconChainCfg.MaxPayloadAttestations)+1) + count := int(handler.beaconChainCfg.MaxPayloadAttestations) + 1 + attestationService.EXPECT().ProcessRESTMessage(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ *cltypes.PayloadAttestationMessage, publish func() error) error { + return publish() + }, + ).Times(count) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNamePayloadAttestation, gomock.Any()).Return(nil).Times(count) request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(body)) request.Header.Set("Content-Type", "application/octet-stream") @@ -265,6 +293,17 @@ func TestPostPayloadAttestationsAcceptsMoreThanBlockAggregateLimitSSZ(t *testing func TestPostPayloadAttestationsAcceptsSSZContentTypeParameters(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + ctrl := gomock.NewController(t) + attestationService := mock_services.NewMockPayloadAttestationService(ctrl) + attestationService.EXPECT().ProcessRESTMessage(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ *cltypes.PayloadAttestationMessage, publish func() error) error { + return publish() + }, + ) + handler.payloadAttestationService = attestationService + gossipManager := gossip_mock.NewMockGossip(ctrl) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNamePayloadAttestation, gomock.Any()).Return(nil) + handler.gossipManager = gossipManager msg := &cltypes.PayloadAttestationMessage{ Data: new(cltypes.PayloadAttestationData), } @@ -340,14 +379,14 @@ func TestPostPayloadAttestationsCapsJSONCardinality(t *testing.T) { } } -func TestPostPayloadAttestationsAcceptsQueuedWithoutPooling(t *testing.T) { +func TestPostPayloadAttestationsRejectsQueuedWithoutPooling(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) ctrl := gomock.NewController(t) defer ctrl.Finish() msg := newTestPayloadAttestationMessage(t, 12, common.HexToHash("0x1234")) attestationService := mock_services.NewMockPayloadAttestationService(ctrl) - attestationService.EXPECT().ProcessMessage(gomock.Any(), gomock.Nil(), gomock.Any()).Return(fmt.Errorf("%w: %w", services.ErrIgnore, services.ErrAttestationQueued)) + attestationService.EXPECT().ProcessRESTMessage(gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("%w: %w", services.ErrIgnore, services.ErrAttestationQueued)) handler.payloadAttestationService = attestationService handler.epbsPool = pool.NewEpbsPool() @@ -360,11 +399,78 @@ func TestPostPayloadAttestationsAcceptsQueuedWithoutPooling(t *testing.T) { handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + _, found := handler.epbsPool.PayloadAttestations.Get(pool.PayloadAttestationKey{Slot: msg.Data.Slot, ValidatorIndex: msg.ValidatorIndex}) + require.False(t, found) +} + +func TestPostPayloadAttestationsAcceptsDuplicateWithoutPooling(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + ctrl := gomock.NewController(t) + msg := newTestPayloadAttestationMessage(t, 12, common.HexToHash("0x1234")) + attestationService := mock_services.NewMockPayloadAttestationService(ctrl) + attestationService.EXPECT().ProcessRESTMessage(gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("%w: %w", services.ErrIgnore, services.ErrAttestationDuplicate)) + handler.payloadAttestationService = attestationService + handler.epbsPool = pool.NewEpbsPool() + body, err := json.Marshal([]*cltypes.PayloadAttestationMessage{msg}) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) _, found := handler.epbsPool.PayloadAttestations.Get(pool.PayloadAttestationKey{Slot: msg.Data.Slot, ValidatorIndex: msg.ValidatorIndex}) require.False(t, found) } +func TestPostPayloadAttestationsReturnsPublishFailure(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + ctrl := gomock.NewController(t) + msg := newTestPayloadAttestationMessage(t, 12, common.HexToHash("0x1234")) + attestationService := mock_services.NewMockPayloadAttestationService(ctrl) + attestationService.EXPECT().ProcessRESTMessage(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ *cltypes.PayloadAttestationMessage, publish func() error) error { + return publish() + }, + ) + handler.payloadAttestationService = attestationService + gossipManager := gossip_mock.NewMockGossip(ctrl) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNamePayloadAttestation, gomock.Any()).Return(errors.New("gossip unavailable")) + handler.gossipManager = gossipManager + body, err := json.Marshal([]*cltypes.PayloadAttestationMessage{msg}) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request) + + require.Equal(t, http.StatusInternalServerError, recorder.Code, recorder.Body.String()) +} + +func TestPostPayloadAttestationsRejectsRetryableValidation(t *testing.T) { + _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + ctrl := gomock.NewController(t) + msg := newTestPayloadAttestationMessage(t, 12, common.HexToHash("0x1234")) + attestationService := mock_services.NewMockPayloadAttestationService(ctrl) + attestationService.EXPECT().ProcessRESTMessage(gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("%w: %w", services.ErrIgnore, services.ErrAttestationRetryable)) + handler.payloadAttestationService = attestationService + body, err := json.Marshal([]*cltypes.PayloadAttestationMessage{msg}) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) +} + func TestPostPayloadAttestationsRejectsMalformedContentType(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) @@ -394,6 +500,11 @@ func TestPostPayloadAttestationsRejectsUnsupportedContentType(t *testing.T) { func TestPostExecutionPayloadEnvelopeAcceptsGossipIntegrationError(t *testing.T) { _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) fcu.OnExecutionPayloadErr = errors.New("invalid execution payload") + handler.emitters = beaconevents.NewEventEmitter() + events := make(chan *beaconevents.EventStream, 1) + subscription := handler.emitters.Operation().Subscribe(events) + defer subscription.Unsubscribe() + fcu.Blocks[common.Hash{}] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 12, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}} request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelope", strings.NewReader(`{}`)) request.Header.Set("Content-Type", "application/json; charset=utf-8") @@ -404,6 +515,7 @@ func TestPostExecutionPayloadEnvelopeAcceptsGossipIntegrationError(t *testing.T) handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) require.Equal(t, http.StatusAccepted, recorder.Code, recorder.Body.String()) + require.Equal(t, beaconevents.OpExecutionPayloadGossip, (<-events).Event) } func TestPostExecutionPayloadEnvelopesRequiresBlobDataHeader(t *testing.T) { @@ -559,9 +671,16 @@ func TestPostExecutionPayloadEnvelopesRejectsEquivocatingBlockBeforeGossip(t *te func TestPostExecutionPayloadEnvelopesReturnsErrorWhenGossipPublishFails(t *testing.T) { _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.emitters = beaconevents.NewEventEmitter() + events := make(chan *beaconevents.EventStream, 4) + subscription := handler.emitters.Operation().Subscribe(events) + defer subscription.Unsubscribe() ctrl := gomock.NewController(t) gossipManager := gossip_mock.NewMockGossip(ctrl) - gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameExecutionPayload, gomock.Any()).Return(errors.New("gossip unavailable")) + gomock.InOrder( + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameExecutionPayload, gomock.Any()).Return(errors.New("gossip unavailable")), + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameExecutionPayload, gomock.Any()).Return(nil), + ) handler.gossipManager = gossipManager root := common.Hash{1} fcu.Blocks = map[common.Hash]*cltypes.SignedBeaconBlock{ @@ -581,6 +700,266 @@ func TestPostExecutionPayloadEnvelopesReturnsErrorWhenGossipPublishFails(t *test require.Equal(t, http.StatusInternalServerError, recorder.Code, recorder.Body.String()) require.Contains(t, recorder.Body.String(), "gossip unavailable") + for range 3 { + <-events + } + fcu.Envelopes[root] = envelope + fcu.OnExecutionPayloadErr = forkchoice.ErrIgnore + request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Blob-Data-Included", "false") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder = httptest.NewRecorder() + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + require.Equal(t, http.StatusAccepted, recorder.Code, recorder.Body.String()) + select { + case event := <-events: + t.Fatalf("retry emitted duplicate event %s", event.Event) + default: + } +} + +func TestPostExecutionPayloadEnvelopeDuplicateDoesNotRepublishOrEmit(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.gossipManager = gossip_mock.NewMockGossip(gomock.NewController(t)) + handler.emitters = beaconevents.NewEventEmitter() + events := make(chan *beaconevents.EventStream, 1) + subscription := handler.emitters.Operation().Subscribe(events) + defer subscription.Unsubscribe() + fcu.OnExecutionPayloadErr = forkchoice.ErrIgnore + persisted := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + fcu.Envelopes[common.Hash{}] = persisted + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + envelope.Signature[0] = 1 + body, err := json.Marshal(envelope) + require.NoError(t, err) + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + request.Header.Set("Eth-Blob-Data-Included", "false") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + + require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String()) + select { + case event := <-events: + t.Fatalf("unexpected event %s", event.Event) + default: + } +} + +func TestPostExecutionPayloadEnvelopeConcurrentIdenticalReconcilesPersistedImport(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.emitters = beaconevents.NewEventEmitter() + events := make(chan *beaconevents.EventStream, 4) + subscription := handler.emitters.Operation().Subscribe(events) + defer subscription.Unsubscribe() + gossipManager := gossip_mock.NewMockGossip(gomock.NewController(t)) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameExecutionPayload, gomock.Any()).Return(nil).Times(2) + handler.gossipManager = gossipManager + root := common.Hash{7} + fcu.Blocks[root] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 12, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}} + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + envelope.Message.BeaconBlockRoot = root + body, err := json.Marshal(envelope) + require.NoError(t, err) + firstPersisted := make(chan struct{}) + releaseFirst := make(chan struct{}) + var calls atomic.Int32 + fcu.OnExecutionPayloadFn = func(_ context.Context, imported *cltypes.SignedExecutionPayloadEnvelope, _, _ bool) error { + if calls.Add(1) == 1 { + fcu.Envelopes[root] = imported + close(firstPersisted) + <-releaseFirst + return nil + } + return forkchoice.ErrIgnore + } + post := func(payload []byte) *httptest.ResponseRecorder { + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", bytes.NewReader(payload)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + request.Header.Set("Eth-Blob-Data-Included", "false") + recorder := httptest.NewRecorder() + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + return recorder + } + firstResult := make(chan *httptest.ResponseRecorder, 1) + go func() { firstResult <- post(body) }() + <-firstPersisted + second := post(body) + close(releaseFirst) + first := <-firstResult + require.ElementsMatch(t, []int{http.StatusOK, http.StatusAccepted}, []int{first.Code, second.Code}) + eventCounts := map[beaconevents.EventTopic]int{} + for range 3 { + eventCounts[(<-events).Event]++ + } + require.Equal(t, 1, eventCounts[beaconevents.OpExecutionPayloadGossip]) + require.Equal(t, 1, eventCounts[beaconevents.OpExecutionPayload]) + require.Equal(t, 1, eventCounts[beaconevents.OpExecutionPayloadAvailable]) + + forgedEnvelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + forgedEnvelope.Message.BeaconBlockRoot = root + forgedEnvelope.Signature[0] = 1 + forged, err := json.Marshal(forgedEnvelope) + require.NoError(t, err) + require.Equal(t, http.StatusServiceUnavailable, post(forged).Code) +} + +func TestPostExecutionPayloadEnvelopeMissingBlockRequiresRetry(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + ctrl := gomock.NewController(t) + gossipManager := gossip_mock.NewMockGossip(ctrl) + handler.gossipManager = gossipManager + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + body, err := json.Marshal(envelope) + require.NoError(t, err) + post := func() *httptest.ResponseRecorder { + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Eth-Consensus-Version", "gloas") + request.Header.Set("Eth-Blob-Data-Included", "false") + recorder := httptest.NewRecorder() + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + return recorder + } + fcu.OnExecutionPayloadErr = forkchoice.ErrIgnore + require.Equal(t, http.StatusServiceUnavailable, post().Code) + fcu.OnExecutionPayloadErr = nil + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameExecutionPayload, gomock.Any()).Return(nil) + require.Equal(t, http.StatusOK, post().Code) +} + +func TestEmitFullHeadV2DropsChangedPayloadSnapshot(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.emitters = beaconevents.NewEventEmitter() + events := make(chan *beaconevents.EventStream, 1) + subscription := handler.emitters.State().Subscribe(events) + defer subscription.Unsubscribe() + root := common.HexToHash("0x1234") + block := &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 1, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}} + fcu.HeadVal = root + fcu.HeadSlotVal = 1 + fcu.HeadPayloadStatusVal = cltypes.PayloadStatusFull + fcu.GetStateAtBlockRootFn = func(common.Hash, bool) (*state.CachingBeaconState, error) { + fcu.HeadPayloadStatusVal = cltypes.PayloadStatusPending + headState := state.New(handler.beaconChainCfg) + headState.SetVersion(clparams.GloasVersion) + require.NoError(t, headState.SetSlot(1)) + require.NoError(t, headState.SetBlockRootAt(0, common.Hash{1})) + return headState, nil + } + + handler.emitFullHeadV2(block, root) + + select { + case event := <-events: + t.Fatalf("unexpected event %s", event.Event) + default: + } +} + +func TestEmitFullHeadV2DropsChangedOptimismSnapshot(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + handler.emitters = beaconevents.NewEventEmitter() + events := make(chan *beaconevents.EventStream, 1) + subscription := handler.emitters.State().Subscribe(events) + defer subscription.Unsubscribe() + root := common.HexToHash("0x1234") + block := &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 1, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}} + fcu.HeadVal = root + fcu.HeadSlotVal = 1 + fcu.HeadPayloadStatusVal = cltypes.PayloadStatusFull + fcu.GetStateAtBlockRootFn = func(common.Hash, bool) (*state.CachingBeaconState, error) { + fcu.IsRootOptimisticVal = true + headState := state.New(handler.beaconChainCfg) + headState.SetVersion(clparams.GloasVersion) + require.NoError(t, headState.SetSlot(1)) + require.NoError(t, headState.SetBlockRootAt(0, common.Hash{1})) + return headState, nil + } + + handler.emitFullHeadV2(block, root) + + select { + case event := <-events: + t.Fatalf("unexpected event %s", event.Event) + default: + } +} + +func TestGetExecutionPayloadEnvelopeDoesNotFinalizeSameSlotSideBranch(t *testing.T) { + db, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + root := common.HexToHash("0x1234") + slot := uint64(12) + fcu.Envelopes[root] = &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + fcu.Blocks[root] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: slot, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}} + fcu.FinalizedSlotVal = slot + fcu.FinalizedCheckpointVal = solid.Checkpoint{Epoch: 2, Root: common.HexToHash("0xbeef")} + fcu.Ancestors[slot] = forkchoice.ForkChoiceNode{Root: common.HexToHash("0xbeef")} + require.NoError(t, db.Update(t.Context(), func(tx kv.RwTx) error { + return beacon_indicies.MarkRootCanonical(t.Context(), tx, slot, common.HexToHash("0xbeef")) + })) + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/execution_payload_envelope/"+root.Hex(), http.NoBody) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), `"finalized":false`) +} + +func TestGetExecutionPayloadEnvelopeFinalizesCheckpointRootOnly(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + root := common.HexToHash("0x1234") + slot := uint64(64) + fcu.Envelopes[root] = &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + fcu.Blocks[root] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: slot, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}} + fcu.FinalizedCheckpointVal = solid.Checkpoint{Epoch: 2, Root: root} + fcu.Ancestors[slot] = forkchoice.ForkChoiceNode{Root: root} + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/execution_payload_envelope/"+root.Hex(), http.NoBody) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), `"finalized":true`) +} + +func TestGetExecutionPayloadEnvelopeFinalityBoundaryMatrix(t *testing.T) { + for _, test := range []struct { + name string + slot uint64 + ancestor bool + finalized bool + }{ + {name: "earlier canonical ancestor", slot: 63, ancestor: true, finalized: true}, + {name: "descendant", slot: 65, ancestor: true, finalized: false}, + {name: "earlier side branch", slot: 63, ancestor: false, finalized: false}, + } { + t.Run(test.name, func(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + root := common.HexToHash("0x1234") + finalizedRoot := common.HexToHash("0xbeef") + fcu.Envelopes[root] = &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + fcu.Blocks[root] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: test.slot, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}} + fcu.FinalizedCheckpointVal = solid.Checkpoint{Epoch: 2, Root: finalizedRoot} + if test.ancestor { + fcu.Ancestors[test.slot] = forkchoice.ForkChoiceNode{Root: root} + } else { + fcu.Ancestors[test.slot] = forkchoice.ForkChoiceNode{Root: common.HexToHash("0xdead")} + } + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/execution_payload_envelope/"+root.Hex(), http.NoBody) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), fmt.Sprintf(`"finalized":%t`, test.finalized)) + }) + } } func TestPostExecutionPayloadEnvelopesClassifiesIntegrationFailureByValidationMode(t *testing.T) { diff --git a/cl/beacon/handler/handler.go b/cl/beacon/handler/handler.go index 3cbda8ba98c..652efa481d9 100644 --- a/cl/beacon/handler/handler.go +++ b/cl/beacon/handler/handler.go @@ -66,10 +66,7 @@ type BlobBundle struct { KzgProofs []common.Bytes48 } -// selfBuildPayload holds the execution payload and requests built by the local EL -// during self-build block production. Cached so broadcastBlock can construct the -// SignedExecutionPayloadEnvelope when the validator publishes the signed block. -// [New in Gloas:EIP7732] +// selfBuildPayload holds local payload data retained for the validator's signed envelope. type selfBuildPayload struct { Payload *cltypes.Eth1Block ExecutionRequests *cltypes.ExecutionRequests @@ -148,11 +145,7 @@ type ApiHandler struct { executionPayloadBidService services.ExecutionPayloadBidService payloadAttestationService services.PayloadAttestationService proposerPreferencesService services.ProposerPreferencesService - // selfBuildPayloads caches the execution payload + requests built by the local - // EL during self-build block production, keyed by the execution block hash. - // When the validator publishes the signed block, broadcastBlock retrieves the - // cached data, wraps it into a SignedExecutionPayloadEnvelope, and broadcasts - // it on the execution_payload gossip topic so the block can reach FULL status. + // selfBuildPayloads retains locally built payload data until the validator signs and publishes its envelope. selfBuildPayloads *lru.Cache[common.Hash, *selfBuildPayload] // selfBuildEnvelopes caches the unsigned ExecutionPayloadEnvelope by slot so // the validator client can retrieve it via diff --git a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go index f9d75890033..10e21f166a7 100644 --- a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go +++ b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go @@ -80,6 +80,7 @@ type ForkChoiceStorageMock struct { Envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope VerifiedPayloads map[common.Hash]bool OnExecutionPayloadErr error + OnExecutionPayloadFn func(context.Context, *cltypes.SignedExecutionPayloadEnvelope, bool, bool) error OnTickFn func(uint64) ValidateExecutionPayloadEnvelopeErr error GetBeaconCommitteeMock func(slot, committeeIndex uint64) ([]uint64, error) @@ -379,6 +380,9 @@ func (f *ForkChoiceStorageMock) OnBlockWithEquivocationCheck( } func (f *ForkChoiceStorageMock) OnExecutionPayload(ctx context.Context, signedEnvelope *cltypes.SignedExecutionPayloadEnvelope, checkBlobData, validatePayload bool) error { + if f.OnExecutionPayloadFn != nil { + return f.OnExecutionPayloadFn(ctx, signedEnvelope, checkBlobData, validatePayload) + } return f.OnExecutionPayloadErr } diff --git a/cl/phase1/forkchoice/on_execution_payload.go b/cl/phase1/forkchoice/on_execution_payload.go index aff685d7a13..4c6523326c1 100644 --- a/cl/phase1/forkchoice/on_execution_payload.go +++ b/cl/phase1/forkchoice/on_execution_payload.go @@ -595,6 +595,9 @@ func (f *ForkChoiceStore) OnExecutionPayload(ctx context.Context, signedEnvelope f.pendingEnvelopes.Add(common.Hash(beaconBlockRoot), indexEnvelope) return fmt.Errorf("OnExecutionPayload: failed to write execution payload indices: %w", err) } + if !applied { + return fmt.Errorf("%w: execution payload envelope already processed", ErrIgnore) + } return nil } diff --git a/cl/phase1/forkchoice/on_execution_payload_test.go b/cl/phase1/forkchoice/on_execution_payload_test.go index e1bf7c5f805..57f783fdf12 100644 --- a/cl/phase1/forkchoice/on_execution_payload_test.go +++ b/cl/phase1/forkchoice/on_execution_payload_test.go @@ -791,7 +791,7 @@ func TestOnExecutionPayloadRedeliveryRepairsMissingIndices(t *testing.T) { graph := &countingEnvelopeReadForkGraph{pendingRetryForkGraph: pendingRetryForkGraph{completed: blockRoot, completedEnvelope: persisted}} f := &ForkChoiceStore{forkGraph: graph, db: db} - require.NoError(t, f.OnExecutionPayload(context.Background(), redelivered, false, true)) + require.ErrorIs(t, f.OnExecutionPayload(context.Background(), redelivered, false, true), ErrIgnore) require.Equal(t, int32(1), graph.reads.Load()) require.Equal(t, 1, db.calls) require.NoError(t, rwdb.View(context.Background(), func(tx kv.Tx) error { @@ -826,7 +826,7 @@ func TestOnExecutionPayloadRedeliveryRepairsZeroHashIndices(t *testing.T) { graph := &countingEnvelopeReadForkGraph{pendingRetryForkGraph: pendingRetryForkGraph{completed: blockRoot, completedEnvelope: persisted}} f := &ForkChoiceStore{forkGraph: graph, db: db} - require.NoError(t, f.OnExecutionPayload(context.Background(), redelivered, false, true)) + require.ErrorIs(t, f.OnExecutionPayload(context.Background(), redelivered, false, true), ErrIgnore) require.Equal(t, int32(1), graph.reads.Load()) require.Equal(t, 1, db.calls) require.NoError(t, rwdb.View(context.Background(), func(tx kv.Tx) error { @@ -908,7 +908,7 @@ func TestOnExecutionPayloadRedeliverySkipsExistingIndices(t *testing.T) { db: db, } - require.NoError(t, f.OnExecutionPayload(context.Background(), redelivered, false, true)) + require.ErrorIs(t, f.OnExecutionPayload(context.Background(), redelivered, false, true), ErrIgnore) require.Zero(t, graph.reads.Load()) require.Zero(t, db.calls) } diff --git a/cl/phase1/network/services/execution_payload_service.go b/cl/phase1/network/services/execution_payload_service.go index ffc68c9401b..1c2c91907ef 100644 --- a/cl/phase1/network/services/execution_payload_service.go +++ b/cl/phase1/network/services/execution_payload_service.go @@ -172,7 +172,7 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64, } // [IGNORE] The envelope is from a slot greater than or equal to the latest finalized slot - finalizedSlot := s.forkchoiceStore.FinalizedSlot() + finalizedSlot := s.forkchoiceStore.FinalizedCheckpoint().Epoch * s.beaconCfg.SlotsPerEpoch if envelope.Payload == nil { return errors.New("nil execution payload") } diff --git a/cl/phase1/network/services/execution_payload_service_test.go b/cl/phase1/network/services/execution_payload_service_test.go index 6c4d90c052e..98b8d8dd7ae 100644 --- a/cl/phase1/network/services/execution_payload_service_test.go +++ b/cl/phase1/network/services/execution_payload_service_test.go @@ -346,11 +346,39 @@ func TestExecutionPayloadServiceSlotBelowFinalized(t *testing.T) { // Set finalized slot higher than envelope slot fcu.FinalizedSlotVal = 100 + fcu.FinalizedCheckpointVal = solid.Checkpoint{Epoch: 2} err := service.ProcessMessage(context.Background(), nil, envelope) require.Error(t, err) require.True(t, errors.Is(err, ErrIgnore)) - require.Contains(t, err.Error(), "envelope slot 50 < finalized slot 100") + require.Contains(t, err.Error(), "envelope slot 50 < finalized slot 64") +} + +func TestExecutionPayloadServiceUsesFinalizedEpochStartBoundary(t *testing.T) { + for _, tc := range []struct { + name string + slot uint64 + ignored bool + }{ + {name: "below", slot: 63, ignored: true}, + {name: "exact", slot: 64}, + {name: "above", slot: 65}, + } { + t.Run(tc.name, func(t *testing.T) { + service, fcu := setupExecutionPayloadService(t) + root := common.Hash{byte(tc.slot)} + fcu.FinalizedCheckpointVal = solid.Checkpoint{Epoch: 2} + fcu.FinalizedSlotVal = 95 + fcu.Blocks[root] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: tc.slot}} + + err := service.ProcessMessage(context.Background(), nil, newTestSignedEnvelope(tc.slot, root, 1)) + if tc.ignored { + require.ErrorIs(t, err, ErrIgnore) + return + } + require.NoError(t, err) + }) + } } func TestExecutionPayloadServiceSuccess(t *testing.T) { diff --git a/cl/phase1/network/services/mock_services/payload_attestation_service_mock.go b/cl/phase1/network/services/mock_services/payload_attestation_service_mock.go index 982b68217d8..51b4d3a46a8 100644 --- a/cl/phase1/network/services/mock_services/payload_attestation_service_mock.go +++ b/cl/phase1/network/services/mock_services/payload_attestation_service_mock.go @@ -157,3 +157,41 @@ func (c *MockPayloadAttestationServiceProcessMessageCall) DoAndReturn(f func(con c.Call = c.Call.DoAndReturn(f) return c } + +// ProcessRESTMessage mocks base method. +func (m *MockPayloadAttestationService) ProcessRESTMessage(arg0 context.Context, arg1 *cltypes.PayloadAttestationMessage, arg2 func() error) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ProcessRESTMessage", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// ProcessRESTMessage indicates an expected call of ProcessRESTMessage. +func (mr *MockPayloadAttestationServiceMockRecorder) ProcessRESTMessage(arg0, arg1, arg2 any) *MockPayloadAttestationServiceProcessRESTMessageCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProcessRESTMessage", reflect.TypeOf((*MockPayloadAttestationService)(nil).ProcessRESTMessage), arg0, arg1, arg2) + return &MockPayloadAttestationServiceProcessRESTMessageCall{Call: call} +} + +// MockPayloadAttestationServiceProcessRESTMessageCall wrap *gomock.Call +type MockPayloadAttestationServiceProcessRESTMessageCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockPayloadAttestationServiceProcessRESTMessageCall) Return(arg0 error) *MockPayloadAttestationServiceProcessRESTMessageCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockPayloadAttestationServiceProcessRESTMessageCall) Do(f func(context.Context, *cltypes.PayloadAttestationMessage, func() error) error) *MockPayloadAttestationServiceProcessRESTMessageCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockPayloadAttestationServiceProcessRESTMessageCall) DoAndReturn(f func(context.Context, *cltypes.PayloadAttestationMessage, func() error) error) *MockPayloadAttestationServiceProcessRESTMessageCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cl/phase1/network/services/payload_attestation_service.go b/cl/phase1/network/services/payload_attestation_service.go index bc7b8545118..b529fb55ea5 100644 --- a/cl/phase1/network/services/payload_attestation_service.go +++ b/cl/phase1/network/services/payload_attestation_service.go @@ -32,6 +32,7 @@ import ( "github.com/erigontech/erigon/cl/gossip" "github.com/erigontech/erigon/cl/phase1/core/state/lru" "github.com/erigontech/erigon/cl/phase1/forkchoice" + "github.com/erigontech/erigon/cl/pool" "github.com/erigontech/erigon/cl/utils/eth_clock" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" @@ -57,6 +58,14 @@ type pendingPayloadAttestationJob struct { creationTime time.Time } +type validatedRESTPayloadAttestation struct { + mu sync.Mutex + messageRoot common.Hash + creationTime time.Time + refs int + validated atomic.Bool +} + const ( // seenPayloadAttestationCacheSize: PTC has 512 validators per slot. // With clock disparity, we may see attestations for ~2 slots. @@ -73,6 +82,7 @@ type payloadAttestationService struct { ethClock eth_clock.EthereumClock netCfg *clparams.NetworkConfig emitters *beaconevents.EventEmitter + epbsPool *pool.EpbsPool // Cache to track seen attestations: (slot, validatorIndex) -> struct{} seenAttestationsCache *lru.Cache[seenPayloadAttestationKey, struct{}] @@ -82,6 +92,8 @@ type payloadAttestationService struct { pendingCount atomic.Int32 pendingCond *sync.Cond validationAdmission chan struct{} + validatedRESTMu sync.Mutex + validatedREST map[seenPayloadAttestationKey]*validatedRESTPayloadAttestation } // NewPayloadAttestationService creates a new payload attestation service. @@ -91,6 +103,7 @@ func NewPayloadAttestationService( forkchoiceStore forkchoice.ForkChoiceStorage, ethClock eth_clock.EthereumClock, netCfg *clparams.NetworkConfig, + epbsPool *pool.EpbsPool, emitters *beaconevents.EventEmitter, ) PayloadAttestationService { seenCache, err := lru.New[seenPayloadAttestationKey, struct{}]("seen_payload_attestations", seenPayloadAttestationCacheSize) @@ -101,10 +114,12 @@ func NewPayloadAttestationService( forkchoiceStore: forkchoiceStore, ethClock: ethClock, netCfg: netCfg, + epbsPool: epbsPool, emitters: emitters, seenAttestationsCache: seenCache, pendingCond: sync.NewCond(&sync.Mutex{}), validationAdmission: make(chan struct{}, maxConcurrentPayloadAttestationValidations), + validatedREST: make(map[seenPayloadAttestationKey]*validatedRESTPayloadAttestation), } go s.loop(ctx) return s @@ -126,6 +141,21 @@ func (s *payloadAttestationService) DecodeGossipMessage(_ peer.ID, data []byte, // Reference: https://github.com/ethereum/consensus-specs/blob/dev/specs/_features/epbs/p2p-interface.md#payload_attestation_message // [New in Gloas:EIP7732] func (s *payloadAttestationService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltypes.PayloadAttestationMessage) error { + return s.processMessage(ctx, msg, true, nil) +} + +func (s *payloadAttestationService) ProcessRESTMessage(ctx context.Context, msg *cltypes.PayloadAttestationMessage, publish func() error) error { + return s.processMessage(ctx, msg, false, publish) +} + +var ( + ErrAttestationDuplicate = errors.New("payload attestation duplicate") + ErrAttestationRetryable = errors.New("payload attestation retryable") + ErrAttestationConflict = errors.New("payload attestation conflicts with validated message") + ErrAttestationCapacity = errors.New("validated payload attestation retry capacity reached") +) + +func (s *payloadAttestationService) processMessage(ctx context.Context, msg *cltypes.PayloadAttestationMessage, queueMissing bool, publish func() error) error { if msg == nil || msg.Data == nil { return fmt.Errorf("nil payload attestation message") } @@ -151,7 +181,7 @@ func (s *payloadAttestationService) ProcessMessage(ctx context.Context, _ *uint6 validatorIndex: validatorIndex, } if s.seenAttestationsCache.Contains(seenKey) { - return fmt.Errorf("%w: already seen payload attestation from validator %d for slot %d", ErrIgnore, validatorIndex, slot) + return fmt.Errorf("%w: %w: already seen payload attestation from validator %d for slot %d", ErrIgnore, ErrAttestationDuplicate, validatorIndex, slot) } // [IGNORE] The message's block root has been seen (via gossip or non-gossip sources) @@ -159,7 +189,9 @@ func (s *payloadAttestationService) ProcessMessage(ctx context.Context, _ *uint6 blockHeader, ok := s.forkchoiceStore.GetHeader(blockRoot) if !ok { // Block hasn't arrived yet, queue attestation for later processing - s.queuePendingAttestation(blockRoot, msg) + if queueMissing { + s.queuePendingAttestation(blockRoot, msg) + } log.Trace("Queued payload attestation for later processing", "blockRoot", blockRoot, "validatorIndex", validatorIndex) @@ -169,40 +201,129 @@ func (s *payloadAttestationService) ProcessMessage(ctx context.Context, _ *uint6 if blockHeader.Slot != slot { return fmt.Errorf("%w: payload attestation slot %d does not match referenced block slot %d", ErrIgnore, slot, blockHeader.Slot) } + messageRoot, err := msg.HashSSZ() + if err != nil { + return fmt.Errorf("hash payload attestation: %w", err) + } + if publish == nil { + coordinator := s.acquireExistingRESTAttestation(seenKey, messageRoot) + if coordinator != nil { + coordinator.mu.Lock() + if coordinator.validated.Load() { + if s.seenAttestationsCache.Contains(seenKey) { + coordinator.mu.Unlock() + s.releaseValidatedRESTAttestation(seenKey, coordinator, false) + return fmt.Errorf("%w: %w: already seen payload attestation from validator %d for slot %d", ErrIgnore, ErrAttestationDuplicate, validatorIndex, slot) + } + s.commitPayloadAttestation(seenKey, msg) + coordinator.mu.Unlock() + s.releaseValidatedRESTAttestation(seenKey, coordinator, false) + return nil + } + coordinator.mu.Unlock() + s.releaseValidatedRESTAttestation(seenKey, coordinator, false) + } + if err := s.validatePayloadAttestation(ctx, msg); err != nil { + return err + } + s.commitPayloadAttestation(seenKey, msg) + return nil + } + coordinator, err := s.acquireValidatedRESTAttestation(seenKey, messageRoot) + if err != nil { + return err + } + retainCoordinator := false + defer func() { s.releaseValidatedRESTAttestation(seenKey, coordinator, retainCoordinator) }() + coordinator.mu.Lock() + defer coordinator.mu.Unlock() + if s.seenAttestationsCache.Contains(seenKey) { + return fmt.Errorf("%w: %w: already seen payload attestation from validator %d for slot %d", ErrIgnore, ErrAttestationDuplicate, validatorIndex, slot) + } + if !coordinator.validated.Load() { + if err := s.validatePayloadAttestation(ctx, msg); err != nil { + return err + } + coordinator.validated.Store(true) + } + if err := publish(); err != nil { + retainCoordinator = true + return err + } + s.commitPayloadAttestation(seenKey, msg) + return nil +} + +func (s *payloadAttestationService) validatePayloadAttestation(ctx context.Context, msg *cltypes.PayloadAttestationMessage) error { select { case s.validationAdmission <- struct{}{}: - defer func() { <-s.validationAdmission }() case <-ctx.Done(): - return fmt.Errorf("%w: payload attestation validation canceled: %v", ErrIgnore, ctx.Err()) //nolint:errorlint // converting cancellation to IGNORE + return fmt.Errorf("%w: %w: payload attestation validation canceled: %v", ErrIgnore, ErrAttestationRetryable, ctx.Err()) //nolint:errorlint // converting cancellation to IGNORE } - - // Process through forkchoice which handles: - // [IGNORE] block state not found - // [REJECT] validator is not in PTC - // [REJECT] signature verification - if err := s.forkchoiceStore.OnPayloadAttestationMessage(ctx, msg, false); err != nil { - // Preserve IGNORE vs REJECT distinction from forkchoice - // forkchoice.ErrIgnore != services.ErrIgnore, so we need to convert - if errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return fmt.Errorf("%w: %v", ErrIgnore, err) //nolint:errorlint // converting, not wrapping: forkchoice.ErrIgnore must not stay matchable - } - return fmt.Errorf("forkchoice rejected payload attestation: %w", err) + err := s.forkchoiceStore.OnPayloadAttestationMessage(ctx, msg, false) + <-s.validationAdmission + if err == nil { + return nil + } + if errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("%w: %w: %v", ErrIgnore, ErrAttestationRetryable, err) //nolint:errorlint // converting forkchoice errors to gossip outcomes } + return fmt.Errorf("forkchoice rejected payload attestation: %w", err) +} - // Mark as seen AFTER successful validation +func (s *payloadAttestationService) commitPayloadAttestation(seenKey seenPayloadAttestationKey, msg *cltypes.PayloadAttestationMessage) { s.seenAttestationsCache.Add(seenKey, struct{}{}) - - // Emit SSE event for payload_attestation_message [New in Gloas:EIP7732] + if s.epbsPool != nil { + s.epbsPool.PayloadAttestations.Add(pool.PayloadAttestationKey{Slot: seenKey.slot, ValidatorIndex: seenKey.validatorIndex}, msg) + } s.emitters.Operation().SendPayloadAttestationMessage(msg) +} - log.Trace("Processed payload attestation message via gossip", - "slot", slot, - "validatorIndex", validatorIndex, - "blockRoot", blockRoot, - "payloadPresent", data.PayloadPresent, - "blobDataAvailable", data.BlobDataAvailable) +func (s *payloadAttestationService) acquireValidatedRESTAttestation(key seenPayloadAttestationKey, messageRoot common.Hash) (*validatedRESTPayloadAttestation, error) { + s.validatedRESTMu.Lock() + defer s.validatedRESTMu.Unlock() + if s.validatedREST == nil { + s.validatedREST = make(map[seenPayloadAttestationKey]*validatedRESTPayloadAttestation) + } + now := time.Now() + for candidateKey, candidate := range s.validatedREST { + if candidate.refs == 0 && now.Sub(candidate.creationTime) > pendingPayloadAttestationExpiry { + delete(s.validatedREST, candidateKey) + } + } + if existing := s.validatedREST[key]; existing != nil { + if existing.messageRoot != messageRoot { + return nil, fmt.Errorf("%w for slot %d validator %d", ErrAttestationConflict, key.slot, key.validatorIndex) + } + existing.refs++ + return existing, nil + } + if len(s.validatedREST) >= maxPendingAttestations { + return nil, ErrAttestationCapacity + } + entry := &validatedRESTPayloadAttestation{messageRoot: messageRoot, creationTime: now, refs: 1} + s.validatedREST[key] = entry + return entry, nil +} - return nil +func (s *payloadAttestationService) acquireExistingRESTAttestation(key seenPayloadAttestationKey, messageRoot common.Hash) *validatedRESTPayloadAttestation { + s.validatedRESTMu.Lock() + defer s.validatedRESTMu.Unlock() + entry := s.validatedREST[key] + if entry == nil || entry.messageRoot != messageRoot { + return nil + } + entry.refs++ + return entry +} + +func (s *payloadAttestationService) releaseValidatedRESTAttestation(key seenPayloadAttestationKey, entry *validatedRESTPayloadAttestation, retain bool) { + s.validatedRESTMu.Lock() + defer s.validatedRESTMu.Unlock() + entry.refs-- + if current := s.validatedREST[key]; current == entry && entry.refs == 0 && !retain { + delete(s.validatedREST, key) + } } // queuePendingAttestation adds an attestation to the pending queue for later processing. @@ -214,10 +335,12 @@ func (s *payloadAttestationService) queuePendingAttestation(blockRoot common.Has key := pendingPayloadAttestationKeyFor(blockRoot, msg) - if _, loaded := s.pendingAttestations.LoadOrStore(key, &pendingPayloadAttestationJob{ + job := &pendingPayloadAttestationJob{ msg: msg, creationTime: time.Now(), - }); loaded { + } + _, loaded := s.pendingAttestations.LoadOrStore(key, job) + if loaded { s.pendingCount.Add(-1) } else { s.pendingCond.L.Lock() @@ -301,14 +424,18 @@ func (s *payloadAttestationService) processPendingAttestations(ctx context.Conte return true // Block still not here, keep waiting } - // Block arrived, remove from pending and process - s.pendingAttestations.Delete(pendingKey) - s.pendingCount.Add(-1) - - // Re-run validation via ProcessMessage - if err := s.ProcessMessage(ctx, nil, job.msg); err != nil { + err := s.processMessage(ctx, job.msg, false, nil) + if err != nil { + if errors.Is(err, ErrAttestationRetryable) { + return true + } + s.pendingAttestations.Delete(pendingKey) + s.pendingCount.Add(-1) log.Trace("Failed to process pending payload attestation", "blockRoot", pendingKey.blockRoot, "err", err) + return true } + s.pendingAttestations.Delete(pendingKey) + s.pendingCount.Add(-1) return true }) } diff --git a/cl/phase1/network/services/payload_attestation_service_test.go b/cl/phase1/network/services/payload_attestation_service_test.go index d8f9b820e16..59d482e0c56 100644 --- a/cl/phase1/network/services/payload_attestation_service_test.go +++ b/cl/phase1/network/services/payload_attestation_service_test.go @@ -33,6 +33,7 @@ import ( "github.com/erigontech/erigon/cl/phase1/core/state/lru" "github.com/erigontech/erigon/cl/phase1/forkchoice" "github.com/erigontech/erigon/cl/phase1/forkchoice/mock_services" + "github.com/erigontech/erigon/cl/pool" "github.com/erigontech/erigon/cl/utils/eth_clock" "github.com/erigontech/erigon/common" ) @@ -70,6 +71,19 @@ type retryPayloadAttestationForkchoice struct { releaseFirst chan struct{} } +type retryablePayloadAttestationForkchoice struct { + forkchoice.ForkChoiceStorage + first error + calls atomic.Int32 +} + +func (f *retryablePayloadAttestationForkchoice) OnPayloadAttestationMessage(context.Context, *cltypes.PayloadAttestationMessage, bool) error { + if f.calls.Add(1) == 1 { + return f.first + } + return nil +} + type candidatePayloadAttestationForkchoice struct { forkchoice.ForkChoiceStorage started chan byte @@ -108,6 +122,7 @@ func setupPayloadAttestationService(t *testing.T, ctrl *gomock.Controller) (*pay netCfg: nil, // Not used in current implementation seenAttestationsCache: seenCache, emitters: beaconevents.NewEventEmitter(), + epbsPool: pool.NewEpbsPool(), pendingCond: sync.NewCond(&sync.Mutex{}), // Needed for queuePendingAttestation validationAdmission: make(chan struct{}, maxConcurrentPayloadAttestationValidations), } @@ -556,6 +571,236 @@ func TestPayloadAttestationServiceSuccess(t *testing.T) { validatorIndex: 42, } require.True(t, service.seenAttestationsCache.Contains(seenKey)) + pooled, ok := service.epbsPool.PayloadAttestations.Get(pool.PayloadAttestationKey{Slot: 100, ValidatorIndex: 42}) + require.True(t, ok) + require.Same(t, msg, pooled) +} + +func TestPayloadAttestationServiceRESTCommitsOnlyAfterPublish(t *testing.T) { + ctrl := gomock.NewController(t) + service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + root := common.HexToHash("0x1234") + msg := newTestPayloadAttestationMessage(100, 42, root) + fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} + ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) + + require.Error(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return errors.New("publish failed") })) + require.False(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42})) + _, ok := service.epbsPool.PayloadAttestations.Get(pool.PayloadAttestationKey{Slot: 100, ValidatorIndex: 42}) + require.False(t, ok) + require.NoError(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return nil })) + require.True(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42})) +} + +type consumeOncePayloadAttestationForkchoice struct { + forkchoice.ForkChoiceStorage + calls atomic.Int32 +} + +type coordinatedPayloadAttestationForkchoice struct { + forkchoice.ForkChoiceStorage + calls atomic.Int32 + firstStarted chan struct{} + releaseFirst chan struct{} + firstErr error +} + +func (f *coordinatedPayloadAttestationForkchoice) OnPayloadAttestationMessage(context.Context, *cltypes.PayloadAttestationMessage, bool) error { + if f.calls.Add(1) == 1 { + close(f.firstStarted) + <-f.releaseFirst + return f.firstErr + } + return nil +} + +func (f *consumeOncePayloadAttestationForkchoice) OnPayloadAttestationMessage(context.Context, *cltypes.PayloadAttestationMessage, bool) error { + if f.calls.Add(1) == 1 { + return nil + } + return forkchoice.ErrIgnore +} + +func TestPayloadAttestationServiceRESTRetriesPublishWithoutRevalidating(t *testing.T) { + ctrl := gomock.NewController(t) + service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + root := common.HexToHash("0x1234") + msg := newTestPayloadAttestationMessage(100, 42, root) + fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} + consumeOnce := &consumeOncePayloadAttestationForkchoice{ForkChoiceStorage: fcu} + service.forkchoiceStore = consumeOnce + ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) + + require.Error(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return errors.New("publish failed") })) + require.NoError(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return nil })) + require.Equal(t, int32(1), consumeOnce.calls.Load()) + require.True(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42})) +} + +func TestPayloadAttestationServiceP2PSatisfiesPendingRESTPublish(t *testing.T) { + ctrl := gomock.NewController(t) + service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + root := common.HexToHash("0x1234") + msg := newTestPayloadAttestationMessage(100, 42, root) + fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} + consumeOnce := &consumeOncePayloadAttestationForkchoice{ForkChoiceStorage: fcu} + service.forkchoiceStore = consumeOnce + ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) + + require.Error(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return errors.New("publish failed") })) + require.NoError(t, service.ProcessMessage(t.Context(), nil, msg)) + require.Equal(t, int32(1), consumeOnce.calls.Load()) + require.True(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42})) +} + +func TestPayloadAttestationServiceMismatchedP2PValidatesBehindPendingREST(t *testing.T) { + ctrl := gomock.NewController(t) + service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + root := common.HexToHash("0x1234") + restMsg := newTestPayloadAttestationMessage(100, 42, root) + p2pMsg := newTestPayloadAttestationMessage(100, 42, root) + p2pMsg.Signature[0] = 1 + fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} + consumeOnce := &consumeOncePayloadAttestationForkchoice{ForkChoiceStorage: fcu} + service.forkchoiceStore = consumeOnce + ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) + + require.Error(t, service.ProcessRESTMessage(t.Context(), restMsg, func() error { return errors.New("publish failed") })) + require.ErrorIs(t, service.ProcessMessage(t.Context(), nil, p2pMsg), ErrAttestationRetryable) + require.Equal(t, int32(2), consumeOnce.calls.Load()) +} + +func TestPayloadAttestationServiceExactP2PJoinsRESTValidationBeforePublishFailure(t *testing.T) { + ctrl := gomock.NewController(t) + service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + root := common.HexToHash("0x1234") + msg := newTestPayloadAttestationMessage(100, 42, root) + fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} + coordinated := &coordinatedPayloadAttestationForkchoice{ + ForkChoiceStorage: fcu, + firstStarted: make(chan struct{}), + releaseFirst: make(chan struct{}), + } + service.forkchoiceStore = coordinated + ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) + restResult := make(chan error, 1) + go func() { + restResult <- service.ProcessRESTMessage(t.Context(), msg, func() error { return errors.New("publish failed") }) + }() + <-coordinated.firstStarted + p2pResult := make(chan error, 1) + go func() { p2pResult <- service.ProcessMessage(t.Context(), nil, msg) }() + waitForValidatedRESTCoordinatorRefs(t, service, seenPayloadAttestationKey{100, 42}, 2) + close(coordinated.releaseFirst) + require.Error(t, <-restResult) + require.NoError(t, <-p2pResult) + require.Equal(t, int32(1), coordinated.calls.Load()) + require.True(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42})) +} + +func TestPayloadAttestationServiceP2PValidatesAfterRESTValidationFailure(t *testing.T) { + ctrl := gomock.NewController(t) + service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + root := common.HexToHash("0x1234") + msg := newTestPayloadAttestationMessage(100, 42, root) + fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} + coordinated := &coordinatedPayloadAttestationForkchoice{ + ForkChoiceStorage: fcu, + firstStarted: make(chan struct{}), + releaseFirst: make(chan struct{}), + firstErr: errors.New("invalid signature"), + } + service.forkchoiceStore = coordinated + ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) + restResult := make(chan error, 1) + var publishCalled atomic.Bool + go func() { + restResult <- service.ProcessRESTMessage(t.Context(), msg, func() error { + publishCalled.Store(true) + return nil + }) + }() + <-coordinated.firstStarted + p2pResult := make(chan error, 1) + go func() { p2pResult <- service.ProcessMessage(t.Context(), nil, msg) }() + waitForValidatedRESTCoordinatorRefs(t, service, seenPayloadAttestationKey{100, 42}, 2) + close(coordinated.releaseFirst) + require.Error(t, <-restResult) + require.NoError(t, <-p2pResult) + require.Equal(t, int32(2), coordinated.calls.Load()) + require.False(t, publishCalled.Load()) +} + +func waitForValidatedRESTCoordinatorRefs(t *testing.T, service *payloadAttestationService, key seenPayloadAttestationKey, refs int) { + t.Helper() + require.Eventually(t, func() bool { + service.validatedRESTMu.Lock() + defer service.validatedRESTMu.Unlock() + entry := service.validatedREST[key] + return entry != nil && entry.refs == refs + }, time.Second, time.Millisecond) +} + +func TestPayloadAttestationServiceRESTPendingRejectsDifferentIdentity(t *testing.T) { + ctrl := gomock.NewController(t) + service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + root := common.HexToHash("0x1234") + first := newTestPayloadAttestationMessage(100, 42, root) + second := newTestPayloadAttestationMessage(100, 42, root) + second.Signature[0] = 1 + fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} + ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) + + require.Error(t, service.ProcessRESTMessage(t.Context(), first, func() error { return errors.New("publish failed") })) + err := service.ProcessRESTMessage(t.Context(), second, func() error { t.Fatal("different identity published"); return nil }) + require.ErrorIs(t, err, ErrAttestationConflict) +} + +func TestPayloadAttestationServiceConcurrentRESTRetryCoalescesValidation(t *testing.T) { + ctrl := gomock.NewController(t) + service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + root := common.HexToHash("0x1234") + msg := newTestPayloadAttestationMessage(100, 42, root) + fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} + consumeOnce := &consumeOncePayloadAttestationForkchoice{ForkChoiceStorage: fcu} + service.forkchoiceStore = consumeOnce + ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) + firstPublishing := make(chan struct{}) + releaseFirst := make(chan struct{}) + results := make(chan error, 2) + go func() { + results <- service.ProcessRESTMessage(t.Context(), msg, func() error { + close(firstPublishing) + <-releaseFirst + return errors.New("publish failed") + }) + }() + <-firstPublishing + go func() { + results <- service.ProcessRESTMessage(t.Context(), msg, func() error { return nil }) + }() + close(releaseFirst) + + firstErr, secondErr := <-results, <-results + require.True(t, (firstErr == nil) != (secondErr == nil)) + require.Equal(t, int32(1), consumeOnce.calls.Load()) + require.True(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42})) +} + +func TestValidatedRESTPayloadAttestationCoordinatorExpiresAndBoundsEntries(t *testing.T) { + service := &payloadAttestationService{validatedREST: make(map[seenPayloadAttestationKey]*validatedRESTPayloadAttestation)} + service.validatedREST[seenPayloadAttestationKey{1, 1}] = &validatedRESTPayloadAttestation{ + creationTime: time.Now().Add(-pendingPayloadAttestationExpiry - time.Second), + } + entry, err := service.acquireValidatedRESTAttestation(seenPayloadAttestationKey{2, 2}, common.Hash{2}) + require.NoError(t, err) + require.Len(t, service.validatedREST, 1) + service.releaseValidatedRESTAttestation(seenPayloadAttestationKey{2, 2}, entry, true) + for i := range maxPendingAttestations - 1 { + service.validatedREST[seenPayloadAttestationKey{slot: uint64(i + 3), validatorIndex: uint64(i + 3)}] = &validatedRESTPayloadAttestation{creationTime: time.Now()} + } + _, err = service.acquireValidatedRESTAttestation(seenPayloadAttestationKey{9999, 9999}, common.Hash{9}) + require.ErrorIs(t, err, ErrAttestationCapacity) } func TestPayloadAttestationServiceDifferentValidatorsSameBlock(t *testing.T) { @@ -719,6 +964,26 @@ func TestPayloadAttestationServiceMultiplePendingForSameBlock(t *testing.T) { require.True(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 2})) } +func TestPayloadAttestationServiceRetainsPendingRetryableValidation(t *testing.T) { + for _, firstErr := range []error{forkchoice.ErrIgnore, context.Canceled} { + t.Run(firstErr.Error(), func(t *testing.T) { + ctrl := gomock.NewController(t) + service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + root := common.HexToHash("0x1234") + msg := newTestPayloadAttestationMessage(100, 42, root) + fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} + service.forkchoiceStore = &retryablePayloadAttestationForkchoice{ForkChoiceStorage: fcu, first: firstErr} + service.queuePendingAttestation(root, msg) + ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(4) + + service.processPendingAttestations(t.Context()) + require.Equal(t, int32(1), service.pendingCount.Load()) + service.processPendingAttestations(t.Context()) + require.Zero(t, service.pendingCount.Load()) + }) + } +} + func TestPayloadAttestationServicePendingQueueCap(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/cl/phase1/network/services/types.go b/cl/phase1/network/services/types.go index d3e046ee18c..ee6a691c77a 100644 --- a/cl/phase1/network/services/types.go +++ b/cl/phase1/network/services/types.go @@ -53,7 +53,10 @@ type ExecutionPayloadService serviceinterface.Service[*cltypes.SignedExecutionPa type ExecutionPayloadBidService serviceinterface.Service[*cltypes.SignedExecutionPayloadBid] //go:generate mockgen -typed=true -destination=./mock_services/payload_attestation_service_mock.go -package=mock_services . PayloadAttestationService -type PayloadAttestationService serviceinterface.Service[*cltypes.PayloadAttestationMessage] +type PayloadAttestationService interface { + serviceinterface.Service[*cltypes.PayloadAttestationMessage] + ProcessRESTMessage(context.Context, *cltypes.PayloadAttestationMessage, func() error) error +} //go:generate mockgen -typed=true -destination=./mock_services/proposer_preferences_service_mock.go -package=mock_services . ProposerPreferencesService type ProposerPreferencesService serviceinterface.Service[*cltypes.SignedProposerPreferences] diff --git a/cl/phase1/stages/forward_sync.go b/cl/phase1/stages/forward_sync.go index b072b47f4c3..aee8d45fdff 100644 --- a/cl/phase1/stages/forward_sync.go +++ b/cl/phase1/stages/forward_sync.go @@ -118,14 +118,8 @@ func processDownloadedBlockBatches(ctx context.Context, logger log.Logger, cfg * // the payload is delivered separately via a SignedExecutionPayloadEnvelope. if block.Version() >= clparams.GloasVersion { if env, ok := envelopes[blockRoot]; ok { - // FULL block: update forkchoice with the envelope (updates eth2Roots, persists to disk). - if fceErr := cfg.forkChoice.OnExecutionPayload(ctx, env, false, shouldValidateForwardSyncPayload(cfg, shouldInsert)); fceErr != nil { - logger.Warn("[Caplin] forward sync: failed to process GLOAS envelope", "slot", block.Block.Slot, "err", fceErr) - } else if shouldInsert { - if err = cfg.blockCollector.AddGloasBlock(block.Block, env); err != nil { - err = fmt.Errorf("failed to add gloas block to collector: %w", err) - return - } + if err = processDownloadedGloasEnvelope(ctx, logger, cfg.forkChoice, cfg.blockCollector, block.Block, blockRoot, env, shouldInsert, shouldValidateForwardSyncPayload(cfg, shouldInsert)); err != nil { + return } } // Dump state periodically for restart checkpoints. @@ -176,6 +170,37 @@ func processDownloadedBlockBatches(ctx context.Context, logger log.Logger, cfg * return } +type gloasBlockCollector interface { + AddGloasBlock(*cltypes.BeaconBlock, *cltypes.SignedExecutionPayloadEnvelope) error +} + +func processDownloadedGloasEnvelope(ctx context.Context, logger log.Logger, store forkchoice.ForkChoiceStorage, collector gloasBlockCollector, block *cltypes.BeaconBlock, blockRoot common.Hash, envelope *cltypes.SignedExecutionPayloadEnvelope, shouldInsert, validate bool) error { + err := store.OnExecutionPayload(ctx, envelope, false, validate) + if err != nil && !(errors.Is(err, forkchoice.ErrIgnore) && persistedEnvelopeMatches(store, blockRoot, envelope)) { + logger.Warn("[Caplin] forward sync: failed to process GLOAS envelope", "slot", block.Slot, "err", err) + return nil + } + if shouldInsert { + if err := collector.AddGloasBlock(block, envelope); err != nil { + return fmt.Errorf("failed to add gloas block to collector: %w", err) + } + } + return nil +} + +func persistedEnvelopeMatches(store forkchoice.ForkChoiceStorage, root common.Hash, envelope *cltypes.SignedExecutionPayloadEnvelope) bool { + persisted, err := store.ReadEnvelopeFromDisk(root) + if err != nil || persisted == nil || envelope == nil { + return false + } + persistedRoot, err := persisted.HashSSZ() + if err != nil { + return false + } + envelopeRoot, err := envelope.HashSSZ() + return err == nil && persistedRoot == envelopeRoot +} + func shouldValidateForwardSyncPayload(cfg *Cfg, shouldInsert bool) bool { return !shouldInsert && canValidateGloasPayloads(cfg) } diff --git a/cl/phase1/stages/gloas_payload_test.go b/cl/phase1/stages/gloas_payload_test.go index 017b77f15ea..353a8cdb1c9 100644 --- a/cl/phase1/stages/gloas_payload_test.go +++ b/cl/phase1/stages/gloas_payload_test.go @@ -24,6 +24,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/empty" "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/execution/engineapi/engine_types" "github.com/erigontech/erigon/execution/protocol/rules/merge" "github.com/erigontech/erigon/execution/types" @@ -391,6 +392,80 @@ func TestGloasRecoveryCursorAdvancesAfterIncompleteFetch(t *testing.T) { require.Equal(t, common.Hash{}, cfg.gloasEnvelopeRecoveryCursor) } +func TestPersistedEnvelopeMatchesExactIdentity(t *testing.T) { + root := common.HexToHash("0x1234") + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)} + envelope.Message.BeaconBlockRoot = root + store := mock_services.NewForkChoiceStorageMock(t) + store.Envelopes[root] = envelope + + require.True(t, persistedEnvelopeMatches(store, root, envelope)) + different := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)} + different.Message.BeaconBlockRoot = root + different.Signature[0] = 1 + require.False(t, persistedEnvelopeMatches(store, root, different)) +} + +type envelopeReadTestStore struct { + forkchoice.ForkChoiceStorage + onErr error + persisted *cltypes.SignedExecutionPayloadEnvelope + readErr error +} + +func (s *envelopeReadTestStore) OnExecutionPayload(context.Context, *cltypes.SignedExecutionPayloadEnvelope, bool, bool) error { + return s.onErr +} + +func (s *envelopeReadTestStore) ReadEnvelopeFromDisk(common.Hash) (*cltypes.SignedExecutionPayloadEnvelope, error) { + return s.persisted, s.readErr +} + +type gloasCollectorTest struct { + calls int + err error +} + +func (c *gloasCollectorTest) AddGloasBlock(*cltypes.BeaconBlock, *cltypes.SignedExecutionPayloadEnvelope) error { + c.calls++ + return c.err +} + +func TestProcessDownloadedGloasEnvelopeCollectorReconciliation(t *testing.T) { + root := common.HexToHash("0x1234") + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)} + envelope.Message.BeaconBlockRoot = root + block := &cltypes.BeaconBlock{Slot: 64} + + t.Run("exact persisted duplicate", func(t *testing.T) { + store := &envelopeReadTestStore{onErr: forkchoice.ErrIgnore, persisted: envelope} + collector := &gloasCollectorTest{} + require.NoError(t, processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false)) + require.Equal(t, 1, collector.calls) + }) + t.Run("mismatch", func(t *testing.T) { + different := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)} + different.Message.BeaconBlockRoot = root + different.Signature[0] = 1 + store := &envelopeReadTestStore{onErr: forkchoice.ErrIgnore, persisted: different} + collector := &gloasCollectorTest{} + require.NoError(t, processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false)) + require.Zero(t, collector.calls) + }) + t.Run("read error", func(t *testing.T) { + store := &envelopeReadTestStore{onErr: forkchoice.ErrIgnore, readErr: errors.New("disk unavailable")} + collector := &gloasCollectorTest{} + require.NoError(t, processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false)) + require.Zero(t, collector.calls) + }) + t.Run("collector error", func(t *testing.T) { + store := &envelopeReadTestStore{} + collector := &gloasCollectorTest{err: errors.New("collector failed")} + err := processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false) + require.ErrorContains(t, err, "collector failed") + }) +} + func TestGloasVerificationItemFailureOnlyStopsOnCancellation(t *testing.T) { completeBatch := true require.True(t, continueGloasVerificationAfterItemFailure(context.Background(), &completeBatch)) diff --git a/cmd/caplin/caplin1/run.go b/cmd/caplin/caplin1/run.go index 36c81bdbfda..6521bd47d9f 100644 --- a/cmd/caplin/caplin1/run.go +++ b/cmd/caplin/caplin1/run.go @@ -481,7 +481,7 @@ func RunCaplinService(ctx context.Context, engine execution_client.ExecutionEngi proposerSlashingService := services.NewProposerSlashingService(pool, syncedDataManager, beaconConfig, ethClock, emitters) attesterSlashingService := services.NewAttesterSlashingService(forkChoice) executionPayloadService := services.NewExecutionPayloadService(ctx, forkChoice, beaconConfig, emitters) - payloadAttestationService := services.NewPayloadAttestationService(ctx, forkChoice, ethClock, networkConfig, emitters) + payloadAttestationService := services.NewPayloadAttestationService(ctx, forkChoice, ethClock, networkConfig, epbsPool, emitters) proposerPreferencesService := services.NewProposerPreferencesService(syncedDataManager, forkChoice, ethClock, beaconConfig, epbsPool, emitters) executionPayloadBidService := services.NewExecutionPayloadBidService(ctx, syncedDataManager, forkChoice, ethClock, beaconConfig, epbsPool, emitters) registry.RegisterGossipServices( From 707d338fd6322b9cc44c57ef4bfac6bb8e552ead Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 17:41:51 +0800 Subject: [PATCH 28/43] cl: fix Gloas review boundaries --- cl/beacon/handler/epbs.go | 1 - cl/beacon/handler/handler.go | 4 +- .../on_payload_attestation_message.go | 6 +-- .../payload_attestation_validation_test.go | 38 +++++++++++++ cl/phase1/network/services/block_service.go | 1 - .../mock_services/block_service_mock.go | 4 +- .../services/payload_attestation_service.go | 22 +++++--- .../payload_attestation_service_test.go | 54 +++++++++++++++++++ cl/phase1/stages/forward_sync.go | 2 +- cl/phase1/stages/gloas_payload_test.go | 11 +++- 10 files changed, 122 insertions(+), 21 deletions(-) diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index ab09949a1bc..6e74a3bb378 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -179,7 +179,6 @@ func (a *ApiHandler) PostEthV1ValidatorDutiesPtc(w http.ResponseWriter, r *http. // ---- Payload Attestation Data ---- // GetEthV1ValidatorPayloadAttestationData returns PayloadAttestationData for PTC validators. -// GET /eth/v1/validator/payload_attestation_data?slot={slot} // [New in Gloas:EIP7732] func (a *ApiHandler) GetEthV1ValidatorPayloadAttestationData(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) { if a.syncedData.Syncing() { diff --git a/cl/beacon/handler/handler.go b/cl/beacon/handler/handler.go index 652efa481d9..0de99fbf42c 100644 --- a/cl/beacon/handler/handler.go +++ b/cl/beacon/handler/handler.go @@ -66,7 +66,6 @@ type BlobBundle struct { KzgProofs []common.Bytes48 } -// selfBuildPayload holds local payload data retained for the validator's signed envelope. type selfBuildPayload struct { Payload *cltypes.Eth1Block ExecutionRequests *cltypes.ExecutionRequests @@ -145,8 +144,7 @@ type ApiHandler struct { executionPayloadBidService services.ExecutionPayloadBidService payloadAttestationService services.PayloadAttestationService proposerPreferencesService services.ProposerPreferencesService - // selfBuildPayloads retains locally built payload data until the validator signs and publishes its envelope. - selfBuildPayloads *lru.Cache[common.Hash, *selfBuildPayload] + selfBuildPayloads *lru.Cache[common.Hash, *selfBuildPayload] // selfBuildEnvelopes caches the unsigned ExecutionPayloadEnvelope by slot so // the validator client can retrieve it via // GET /eth/v1/validator/execution_payload_envelope/{slot}/{builder_index}. diff --git a/cl/phase1/forkchoice/on_payload_attestation_message.go b/cl/phase1/forkchoice/on_payload_attestation_message.go index ca29bc1e904..5e49ec893d2 100644 --- a/cl/phase1/forkchoice/on_payload_attestation_message.go +++ b/cl/phase1/forkchoice/on_payload_attestation_message.go @@ -45,10 +45,8 @@ func (f *ForkChoiceStore) OnPayloadAttestationMessage( blockRoot := data.BeaconBlockRoot if !isFromBlock { - // Wall-clock time is authoritative for gossip because store time can lag OnTick. - currentSlot := f.ethClock.GetCurrentSlot() - if data.Slot != currentSlot { - return fmt.Errorf("%w: attestation slot %d is not current slot %d", ErrIgnore, data.Slot, currentSlot) + if !f.ethClock.IsSlotCurrentSlotWithMaximumClockDisparity(data.Slot) { + return fmt.Errorf("%w: attestation slot %d is not current slot with clock disparity", ErrIgnore, data.Slot) } } diff --git a/cl/phase1/forkchoice/payload_attestation_validation_test.go b/cl/phase1/forkchoice/payload_attestation_validation_test.go index 000c0a8191b..65218fad36d 100644 --- a/cl/phase1/forkchoice/payload_attestation_validation_test.go +++ b/cl/phase1/forkchoice/payload_attestation_validation_test.go @@ -25,12 +25,14 @@ import ( "time" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/fork" "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/utils/bls" + "github.com/erigontech/erigon/cl/utils/eth_clock" "github.com/erigontech/erigon/common" ) @@ -85,6 +87,42 @@ func TestOnPayloadAttestationMessageIgnoresUnavailableKnownBlockState(t *testing require.ErrorIs(t, err, ErrIgnore) } +func TestOnPayloadAttestationMessageAcceptsClockDisparityBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + slot uint64 + }{ + {name: "lower", slot: 99}, + {name: "upper", slot: 101}, + } { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + clock := eth_clock.NewMockEthereumClock(ctrl) + clock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(tc.slot).Return(true) + + root := common.Hash{byte(tc.slot)} + contexts, err := newPayloadAttestationValidationContexts() + require.NoError(t, err) + f := &ForkChoiceStore{ + ethClock: clock, + forkGraph: &getFinalizedExecutionHashForkGraph{ + headers: map[common.Hash]*cltypes.BeaconBlockHeader{root: {Slot: tc.slot}}, + states: map[common.Hash]*state.CachingBeaconState{}, + }, + payloadAttestationContexts: contexts, + } + msg := &cltypes.PayloadAttestationMessage{ + Data: &cltypes.PayloadAttestationData{Slot: tc.slot, BeaconBlockRoot: root}, + } + + err = f.OnPayloadAttestationMessage(context.Background(), msg, false) + + require.ErrorIs(t, err, ErrIgnore) + require.NotContains(t, err.Error(), "not current slot") + }) + } +} + func TestApplyValidatedPayloadAttestationAcceptsOnlyFirstGossipVote(t *testing.T) { f := &ForkChoiceStore{} root := common.HexToHash("0x1234") diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index 8ddca261648..8c17eafa98f 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -136,7 +136,6 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp return err } b.publishBlockGossipEvent(msg) - // the rest of the validation is done in the forkchoice store if err := b.processAndStoreBlock(ctx, msg); err != nil { if errors.Is(err, forkchoice.ErrEIP4844DataNotAvailable) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) || errors.Is(err, forkchoice.ErrParentEnvelopePending) { b.ScheduleBlockForLaterProcessing(msg) diff --git a/cl/phase1/network/services/mock_services/block_service_mock.go b/cl/phase1/network/services/mock_services/block_service_mock.go index eb33748b881..e903c51148a 100644 --- a/cl/phase1/network/services/mock_services/block_service_mock.go +++ b/cl/phase1/network/services/mock_services/block_service_mock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: ./cl/phase1/network/services (interfaces: BlockService) +// Source: github.com/erigontech/erigon/cl/phase1/network/services (interfaces: BlockService) // // Generated by this command: // -// mockgen -typed=true -destination=./cl/phase1/network/services/mock_services/block_service_mock.go -package=mock_services ./cl/phase1/network/services BlockService +// mockgen -typed=true -destination=./mock_services/block_service_mock.go -package=mock_services . BlockService // // Package mock_services is a generated GoMock package. diff --git a/cl/phase1/network/services/payload_attestation_service.go b/cl/phase1/network/services/payload_attestation_service.go index b529fb55ea5..8d749efa1b0 100644 --- a/cl/phase1/network/services/payload_attestation_service.go +++ b/cl/phase1/network/services/payload_attestation_service.go @@ -188,9 +188,11 @@ func (s *payloadAttestationService) processMessage(ctx context.Context, msg *clt // A client MAY queue attestation for processing once the block is retrieved. blockHeader, ok := s.forkchoiceStore.GetHeader(blockRoot) if !ok { - // Block hasn't arrived yet, queue attestation for later processing - if queueMissing { - s.queuePendingAttestation(blockRoot, msg) + if !queueMissing { + return fmt.Errorf("%w: block not available", ErrIgnore) + } + if !s.queuePendingAttestation(blockRoot, msg) { + return fmt.Errorf("%w: %w: block not available", ErrIgnore, ErrAttestationCapacity) } log.Trace("Queued payload attestation for later processing", "blockRoot", blockRoot, @@ -327,14 +329,19 @@ func (s *payloadAttestationService) releaseValidatedRESTAttestation(key seenPayl } // queuePendingAttestation adds an attestation to the pending queue for later processing. -func (s *payloadAttestationService) queuePendingAttestation(blockRoot common.Hash, msg *cltypes.PayloadAttestationMessage) { +func (s *payloadAttestationService) queuePendingAttestation(blockRoot common.Hash, msg *cltypes.PayloadAttestationMessage) bool { + key := pendingPayloadAttestationKeyFor(blockRoot, msg) + if _, loaded := s.pendingAttestations.Load(key); loaded { + return true + } if s.pendingCount.Add(1) > maxPendingAttestations { s.pendingCount.Add(-1) - return + if _, loaded := s.pendingAttestations.Load(key); loaded { + return true + } + return false } - key := pendingPayloadAttestationKeyFor(blockRoot, msg) - job := &pendingPayloadAttestationJob{ msg: msg, creationTime: time.Now(), @@ -347,6 +354,7 @@ func (s *payloadAttestationService) queuePendingAttestation(blockRoot common.Has s.pendingCond.Signal() s.pendingCond.L.Unlock() } + return true } func pendingPayloadAttestationKeyFor(blockRoot common.Hash, msg *cltypes.PayloadAttestationMessage) pendingPayloadAttestationKey { diff --git a/cl/phase1/network/services/payload_attestation_service_test.go b/cl/phase1/network/services/payload_attestation_service_test.go index 59d482e0c56..8e86e58dc81 100644 --- a/cl/phase1/network/services/payload_attestation_service_test.go +++ b/cl/phase1/network/services/payload_attestation_service_test.go @@ -500,6 +500,60 @@ func TestPayloadAttestationServiceBlockNotFound(t *testing.T) { require.True(t, exists) } +func TestPayloadAttestationServiceReportsCapacityWhenMissingBlockQueueIsFull(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service, _, ethClockMock := setupPayloadAttestationService(t, ctrl) + for i := range maxPendingAttestations { + key := pendingPayloadAttestationKey{ + blockRoot: common.Hash{byte(i), byte(i >> 8)}, + validatorIndex: uint64(i), + messageRoot: common.Hash{byte(i >> 8), byte(i)}, + } + service.pendingAttestations.Store(key, &pendingPayloadAttestationJob{}) + } + service.pendingCount.Store(maxPendingAttestations) + + blockRoot := common.HexToHash("0x1234") + msg := newTestPayloadAttestationMessage(100, 1, blockRoot) + ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true) + + err := service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + require.ErrorIs(t, err, ErrAttestationCapacity) + require.NotErrorIs(t, err, ErrAttestationQueued) + require.Equal(t, int32(maxPendingAttestations), service.pendingCount.Load()) + _, exists := service.pendingAttestations.Load(pendingPayloadAttestationKeyFor(blockRoot, msg)) + require.False(t, exists) +} + +func TestPayloadAttestationServiceReportsQueuedWhenExactWorkExistsAtCapacity(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service, _, ethClockMock := setupPayloadAttestationService(t, ctrl) + blockRoot := common.HexToHash("0x1234") + msg := newTestPayloadAttestationMessage(100, 1, blockRoot) + service.pendingAttestations.Store(pendingPayloadAttestationKeyFor(blockRoot, msg), &pendingPayloadAttestationJob{msg: msg}) + for i := range maxPendingAttestations - 1 { + key := pendingPayloadAttestationKey{ + blockRoot: common.Hash{byte(i), byte(i >> 8)}, + validatorIndex: uint64(i + 2), + messageRoot: common.Hash{byte(i >> 8), byte(i)}, + } + service.pendingAttestations.Store(key, &pendingPayloadAttestationJob{}) + } + service.pendingCount.Store(maxPendingAttestations) + ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true) + + err := service.ProcessMessage(context.Background(), nil, msg) + require.ErrorIs(t, err, ErrIgnore) + require.ErrorIs(t, err, ErrAttestationQueued) + require.NotErrorIs(t, err, ErrAttestationCapacity) + require.Equal(t, int32(maxPendingAttestations), service.pendingCount.Load()) +} + func TestPayloadAttestationServicePendingQueueKeepsDistinctSameValidatorBlock(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/cl/phase1/stages/forward_sync.go b/cl/phase1/stages/forward_sync.go index aee8d45fdff..74972627b11 100644 --- a/cl/phase1/stages/forward_sync.go +++ b/cl/phase1/stages/forward_sync.go @@ -178,7 +178,7 @@ func processDownloadedGloasEnvelope(ctx context.Context, logger log.Logger, stor err := store.OnExecutionPayload(ctx, envelope, false, validate) if err != nil && !(errors.Is(err, forkchoice.ErrIgnore) && persistedEnvelopeMatches(store, blockRoot, envelope)) { logger.Warn("[Caplin] forward sync: failed to process GLOAS envelope", "slot", block.Slot, "err", err) - return nil + return err } if shouldInsert { if err := collector.AddGloasBlock(block, envelope); err != nil { diff --git a/cl/phase1/stages/gloas_payload_test.go b/cl/phase1/stages/gloas_payload_test.go index 353a8cdb1c9..edb1f65801e 100644 --- a/cl/phase1/stages/gloas_payload_test.go +++ b/cl/phase1/stages/gloas_payload_test.go @@ -449,13 +449,20 @@ func TestProcessDownloadedGloasEnvelopeCollectorReconciliation(t *testing.T) { different.Signature[0] = 1 store := &envelopeReadTestStore{onErr: forkchoice.ErrIgnore, persisted: different} collector := &gloasCollectorTest{} - require.NoError(t, processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false)) + require.ErrorIs(t, processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false), forkchoice.ErrIgnore) require.Zero(t, collector.calls) }) t.Run("read error", func(t *testing.T) { store := &envelopeReadTestStore{onErr: forkchoice.ErrIgnore, readErr: errors.New("disk unavailable")} collector := &gloasCollectorTest{} - require.NoError(t, processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false)) + require.ErrorIs(t, processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false), forkchoice.ErrIgnore) + require.Zero(t, collector.calls) + }) + t.Run("validation failure", func(t *testing.T) { + validationErr := errors.New("invalid execution payload") + store := &envelopeReadTestStore{onErr: validationErr} + collector := &gloasCollectorTest{} + require.ErrorIs(t, processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false), validationErr) require.Zero(t, collector.calls) }) t.Run("collector error", func(t *testing.T) { From 251cb7e9accec2aa4f1c5a7b788e320fa3a4e989 Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 18:29:11 +0800 Subject: [PATCH 29/43] cl: preserve Gloas timing and sync retry semantics --- .../on_payload_attestation_message.go | 6 - .../payload_attestation_validation_test.go | 52 ++---- .../services/payload_attestation_service.go | 36 +++- .../payload_attestation_service_test.go | 160 ++++++++---------- cl/phase1/stages/forward_sync.go | 2 +- cl/phase1/stages/gloas_payload_test.go | 86 ++++++++++ 6 files changed, 211 insertions(+), 131 deletions(-) diff --git a/cl/phase1/forkchoice/on_payload_attestation_message.go b/cl/phase1/forkchoice/on_payload_attestation_message.go index 5e49ec893d2..32930ec5775 100644 --- a/cl/phase1/forkchoice/on_payload_attestation_message.go +++ b/cl/phase1/forkchoice/on_payload_attestation_message.go @@ -44,12 +44,6 @@ func (f *ForkChoiceStore) OnPayloadAttestationMessage( data := msg.Data blockRoot := data.BeaconBlockRoot - if !isFromBlock { - if !f.ethClock.IsSlotCurrentSlotWithMaximumClockDisparity(data.Slot) { - return fmt.Errorf("%w: attestation slot %d is not current slot with clock disparity", ErrIgnore, data.Slot) - } - } - validationContext, err := f.payloadAttestationValidationContext(ctx, blockRoot, data.Slot) if err != nil { return err diff --git a/cl/phase1/forkchoice/payload_attestation_validation_test.go b/cl/phase1/forkchoice/payload_attestation_validation_test.go index 65218fad36d..97dcd861b25 100644 --- a/cl/phase1/forkchoice/payload_attestation_validation_test.go +++ b/cl/phase1/forkchoice/payload_attestation_validation_test.go @@ -25,14 +25,12 @@ import ( "time" "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/fork" "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/utils/bls" - "github.com/erigontech/erigon/cl/utils/eth_clock" "github.com/erigontech/erigon/common" ) @@ -87,40 +85,24 @@ func TestOnPayloadAttestationMessageIgnoresUnavailableKnownBlockState(t *testing require.ErrorIs(t, err, ErrIgnore) } -func TestOnPayloadAttestationMessageAcceptsClockDisparityBoundaries(t *testing.T) { - for _, tc := range []struct { - name string - slot uint64 - }{ - {name: "lower", slot: 99}, - {name: "upper", slot: 101}, - } { - t.Run(tc.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - clock := eth_clock.NewMockEthereumClock(ctrl) - clock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(tc.slot).Return(true) - - root := common.Hash{byte(tc.slot)} - contexts, err := newPayloadAttestationValidationContexts() - require.NoError(t, err) - f := &ForkChoiceStore{ - ethClock: clock, - forkGraph: &getFinalizedExecutionHashForkGraph{ - headers: map[common.Hash]*cltypes.BeaconBlockHeader{root: {Slot: tc.slot}}, - states: map[common.Hash]*state.CachingBeaconState{}, - }, - payloadAttestationContexts: contexts, - } - msg := &cltypes.PayloadAttestationMessage{ - Data: &cltypes.PayloadAttestationData{Slot: tc.slot, BeaconBlockRoot: root}, - } - - err = f.OnPayloadAttestationMessage(context.Background(), msg, false) - - require.ErrorIs(t, err, ErrIgnore) - require.NotContains(t, err.Error(), "not current slot") - }) +func TestOnPayloadAttestationMessageWireReliesOnIngressClockValidation(t *testing.T) { + root := common.HexToHash("0x1234") + contexts, err := newPayloadAttestationValidationContexts() + require.NoError(t, err) + f := &ForkChoiceStore{ + forkGraph: &getFinalizedExecutionHashForkGraph{ + headers: map[common.Hash]*cltypes.BeaconBlockHeader{root: {Slot: 100}}, + states: map[common.Hash]*state.CachingBeaconState{}, + }, + payloadAttestationContexts: contexts, } + msg := &cltypes.PayloadAttestationMessage{ + Data: &cltypes.PayloadAttestationData{Slot: 100, BeaconBlockRoot: root}, + } + + err = f.OnPayloadAttestationMessage(context.Background(), msg, false) + + require.ErrorIs(t, err, ErrIgnore) } func TestApplyValidatedPayloadAttestationAcceptsOnlyFirstGossipVote(t *testing.T) { diff --git a/cl/phase1/network/services/payload_attestation_service.go b/cl/phase1/network/services/payload_attestation_service.go index 8d749efa1b0..89bdf2df14c 100644 --- a/cl/phase1/network/services/payload_attestation_service.go +++ b/cl/phase1/network/services/payload_attestation_service.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "math" "sync" "sync/atomic" "time" @@ -94,6 +95,7 @@ type payloadAttestationService struct { validationAdmission chan struct{} validatedRESTMu sync.Mutex validatedREST map[seenPayloadAttestationKey]*validatedRESTPayloadAttestation + now func() time.Time } // NewPayloadAttestationService creates a new payload attestation service. @@ -120,6 +122,7 @@ func NewPayloadAttestationService( pendingCond: sync.NewCond(&sync.Mutex{}), validationAdmission: make(chan struct{}, maxConcurrentPayloadAttestationValidations), validatedREST: make(map[seenPayloadAttestationKey]*validatedRESTPayloadAttestation), + now: time.Now, } go s.loop(ctx) return s @@ -170,8 +173,7 @@ func (s *payloadAttestationService) processMessage(ctx context.Context, msg *clt "validatorIndex", validatorIndex, "blockRoot", blockRoot) - // [IGNORE] The message's slot is for the current slot (with a MAXIMUM_GOSSIP_CLOCK_DISPARITY allowance) - if !s.ethClock.IsSlotCurrentSlotWithMaximumClockDisparity(slot) { + if !isPayloadAttestationSlotCurrent(s.ethClock, s.now(), slot) { return fmt.Errorf("%w: payload attestation slot %d is not current slot (with clock disparity)", ErrIgnore, slot) } @@ -256,6 +258,34 @@ func (s *payloadAttestationService) processMessage(ctx context.Context, msg *clt return nil } +func isPayloadAttestationSlotCurrent(clock eth_clock.EthereumClock, now time.Time, slot uint64) bool { + if slot == math.MaxUint64 { + return false + } + slotStart := clock.GetSlotTime(slot) + nextSlotStart := clock.GetSlotTime(slot + 1) + slotUnix := slotStart.Unix() + nextSlotUnix := nextSlotStart.Unix() + if slotUnix < 0 || nextSlotUnix <= slotUnix { + return false + } + secondsPerSlot := uint64(nextSlotUnix - slotUnix) + genesisTime := clock.GenesisTime() + if genesisTime > math.MaxInt64 || slot > (math.MaxUint64-genesisTime)/secondsPerSlot { + return false + } + expectedSlotUnix := genesisTime + slot*secondsPerSlot + if expectedSlotUnix > math.MaxInt64 || slotUnix != int64(expectedSlotUnix) || uint64(nextSlotUnix)-expectedSlotUnix != secondsPerSlot { + return false + } + lowerBound := slotStart.Add(-gloasMaximumClockDisparity) + upperBound := nextSlotStart.Add(gloasMaximumClockDisparity) + if lowerBound.After(slotStart) || upperBound.Before(nextSlotStart) { + return false + } + return !now.Before(lowerBound) && !now.After(upperBound) +} + func (s *payloadAttestationService) validatePayloadAttestation(ctx context.Context, msg *cltypes.PayloadAttestationMessage) error { select { case s.validationAdmission <- struct{}{}: @@ -420,7 +450,7 @@ func (s *payloadAttestationService) processPendingAttestations(ctx context.Conte } // Check if attestation is still for current slot (with clock disparity allowance) - if !s.ethClock.IsSlotCurrentSlotWithMaximumClockDisparity(job.msg.Data.Slot) { + if !isPayloadAttestationSlotCurrent(s.ethClock, s.now(), job.msg.Data.Slot) { s.pendingAttestations.Delete(pendingKey) s.pendingCount.Add(-1) log.Trace("Pending payload attestation slot mismatch", "blockRoot", pendingKey.blockRoot) diff --git a/cl/phase1/network/services/payload_attestation_service_test.go b/cl/phase1/network/services/payload_attestation_service_test.go index 8e86e58dc81..a810582fa7f 100644 --- a/cl/phase1/network/services/payload_attestation_service_test.go +++ b/cl/phase1/network/services/payload_attestation_service_test.go @@ -19,6 +19,7 @@ package services import ( "context" "errors" + "math" "sync" "sync/atomic" "testing" @@ -112,6 +113,10 @@ func (f *retryPayloadAttestationForkchoice) OnPayloadAttestationMessage(context. func setupPayloadAttestationService(t *testing.T, ctrl *gomock.Controller) (*payloadAttestationService, *mock_services.ForkChoiceStorageMock, *eth_clock.MockEthereumClock) { forkchoiceMock := mock_services.NewForkChoiceStorageMock(t) ethClockMock := eth_clock.NewMockEthereumClock(ctrl) + ethClockMock.EXPECT().GenesisTime().Return(uint64(0)).AnyTimes() + ethClockMock.EXPECT().GetSlotTime(gomock.Any()).DoAndReturn(func(slot uint64) time.Time { + return time.Unix(int64(slot*12), 0) + }).AnyTimes() seenCache, err := lru.New[seenPayloadAttestationKey, struct{}]("seen_payload_attestations", seenPayloadAttestationCacheSize) require.NoError(t, err) @@ -125,6 +130,7 @@ func setupPayloadAttestationService(t *testing.T, ctrl *gomock.Controller) (*pay epbsPool: pool.NewEpbsPool(), pendingCond: sync.NewCond(&sync.Mutex{}), // Needed for queuePendingAttestation validationAdmission: make(chan struct{}, maxConcurrentPayloadAttestationValidations), + now: func() time.Time { return time.Unix(100*12+6, 0) }, } return service, forkchoiceMock, ethClockMock @@ -134,7 +140,7 @@ func TestPayloadAttestationServiceBoundsKnownBlockValidation(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) service.validationAdmission = make(chan struct{}, 1) blockRoot := common.HexToHash("0x1234") fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100} @@ -144,8 +150,6 @@ func TestPayloadAttestationServiceBoundsKnownBlockValidation(t *testing.T) { release: make(chan struct{}), } service.forkchoiceStore = blockingForkchoice - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) - firstDone := make(chan error, 1) go func() { firstDone <- service.ProcessMessage(context.Background(), nil, &cltypes.PayloadAttestationMessage{ @@ -170,7 +174,7 @@ func TestPayloadAttestationServiceBackpressuresInsteadOfDroppingValidCandidate(t ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) service.validationAdmission = make(chan struct{}, 1) blockRoot := common.HexToHash("0x1234") fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100} @@ -180,8 +184,6 @@ func TestPayloadAttestationServiceBackpressuresInsteadOfDroppingValidCandidate(t release: make(chan struct{}), } service.forkchoiceStore = validations - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) - invalid := newTestPayloadAttestationMessage(100, 1, blockRoot) invalid.Signature[0] = 1 firstResult := make(chan error, 1) @@ -208,7 +210,7 @@ func TestPayloadAttestationServiceDoesNotDropValidCandidateBehindInvalidCandidat ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100} blockingForkchoice := &candidatePayloadAttestationForkchoice{ @@ -217,8 +219,6 @@ func TestPayloadAttestationServiceDoesNotDropValidCandidateBehindInvalidCandidat release: make(chan struct{}), } service.forkchoiceStore = blockingForkchoice - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(3) - results := make(chan error, 3) for candidate := byte(1); candidate <= 3; candidate++ { msg := newTestPayloadAttestationMessage(100, 1, blockRoot) @@ -251,7 +251,7 @@ func TestPayloadAttestationServiceAllowsConcurrentValidationForDifferentValidato ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100} @@ -261,8 +261,6 @@ func TestPayloadAttestationServiceAllowsConcurrentValidationForDifferentValidato release: make(chan struct{}), } service.forkchoiceStore = blockingForkchoice - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(8) - var wg sync.WaitGroup results := make(chan error, 8) for i := range 8 { @@ -294,7 +292,7 @@ func TestPayloadAttestationServiceAllowsConcurrentValidationForSameValidator(t * ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100} @@ -304,8 +302,6 @@ func TestPayloadAttestationServiceAllowsConcurrentValidationForSameValidator(t * release: make(chan struct{}), } service.forkchoiceStore = blockingForkchoice - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) - results := make(chan error, 2) for range 2 { go func() { @@ -331,7 +327,7 @@ func TestPayloadAttestationServiceRetriesAfterInvalidDuplicate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100} @@ -341,8 +337,6 @@ func TestPayloadAttestationServiceRetriesAfterInvalidDuplicate(t *testing.T) { releaseFirst: make(chan struct{}), } service.forkchoiceStore = retryForkchoice - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) - firstResult := make(chan error, 1) go func() { firstResult <- service.ProcessMessage(context.Background(), nil, newTestPayloadAttestationMessage(100, 42, blockRoot)) @@ -368,7 +362,7 @@ func TestPayloadAttestationServiceIgnoresCanceledDuplicateWaiter(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100} blockingForkchoice := &blockingPayloadAttestationForkchoice{ @@ -377,8 +371,6 @@ func TestPayloadAttestationServiceIgnoresCanceledDuplicateWaiter(t *testing.T) { release: make(chan struct{}), } service.forkchoiceStore = blockingForkchoice - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) - firstResult := make(chan error, 1) go func() { firstResult <- service.ProcessMessage(context.Background(), nil, newTestPayloadAttestationMessage(100, 42, blockRoot)) @@ -428,13 +420,12 @@ func TestPayloadAttestationServiceSlotMismatch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, _, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, _, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 1, blockRoot) - // Mock: slot 100 is NOT current slot (with disparity) - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(false) + service.now = func() time.Time { return time.Unix(100*12, 0).Add(-gloasMaximumClockDisparity - time.Millisecond) } err := service.ProcessMessage(context.Background(), nil, msg) require.Error(t, err) @@ -442,11 +433,50 @@ func TestPayloadAttestationServiceSlotMismatch(t *testing.T) { require.Contains(t, err.Error(), "not current slot") } +func TestPayloadAttestationServiceRejectsTooEarlyNextSlot(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service, _, _ := setupPayloadAttestationService(t, ctrl) + service.now = func() time.Time { return time.Unix(100*12, 0).Add(-gloasMaximumClockDisparity - time.Millisecond) } + + err := service.ProcessMessage(context.Background(), nil, newTestPayloadAttestationMessage(100, 1, common.HexToHash("0x1234"))) + require.ErrorIs(t, err, ErrIgnore) + require.NotErrorIs(t, err, ErrAttestationQueued) +} + +func TestPayloadAttestationSlotCurrentBoundaries(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service, _, _ := setupPayloadAttestationService(t, ctrl) + slotStart := service.ethClock.GetSlotTime(100) + nextSlotStart := service.ethClock.GetSlotTime(101) + for _, tc := range []struct { + name string + now time.Time + slot uint64 + want bool + }{ + {name: "too early next slot", now: slotStart.Add(-gloasMaximumClockDisparity - time.Millisecond), slot: 100, want: false}, + {name: "exact lower boundary", now: slotStart.Add(-gloasMaximumClockDisparity), slot: 100, want: true}, + {name: "current slot interior", now: slotStart.Add(6 * time.Second), slot: 100, want: true}, + {name: "exact upper boundary", now: nextSlotStart.Add(gloasMaximumClockDisparity), slot: 100, want: true}, + {name: "too late previous slot", now: nextSlotStart.Add(gloasMaximumClockDisparity + time.Millisecond), slot: 100, want: false}, + {name: "slot time overflow", now: slotStart, slot: uint64(math.MaxInt64/12) + 1, want: false}, + {name: "maximum slot", now: slotStart, slot: math.MaxUint64, want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, isPayloadAttestationSlotCurrent(service.ethClock, tc.now, tc.slot)) + }) + } +} + func TestPayloadAttestationServiceDuplicateValidator(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 42, blockRoot) @@ -456,16 +486,10 @@ func TestPayloadAttestationServiceDuplicateValidator(t *testing.T) { Slot: 100, } - // First call setup - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true) - // First call should succeed err := service.ProcessMessage(context.Background(), nil, msg) require.NoError(t, err) - // Second call setup - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true) - // Second call with same (slot, validatorIndex) should be ignored err = service.ProcessMessage(context.Background(), nil, msg) require.Error(t, err) @@ -477,14 +501,11 @@ func TestPayloadAttestationServiceBlockNotFound(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, _, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, _, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 1, blockRoot) - // Mock: slot is current - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true) - // Block not in forkchoice - should queue and report queued. err := service.ProcessMessage(context.Background(), nil, msg) require.Error(t, err) @@ -504,7 +525,7 @@ func TestPayloadAttestationServiceReportsCapacityWhenMissingBlockQueueIsFull(t * ctrl := gomock.NewController(t) defer ctrl.Finish() - service, _, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, _, _ := setupPayloadAttestationService(t, ctrl) for i := range maxPendingAttestations { key := pendingPayloadAttestationKey{ blockRoot: common.Hash{byte(i), byte(i >> 8)}, @@ -517,8 +538,6 @@ func TestPayloadAttestationServiceReportsCapacityWhenMissingBlockQueueIsFull(t * blockRoot := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 1, blockRoot) - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true) - err := service.ProcessMessage(context.Background(), nil, msg) require.ErrorIs(t, err, ErrIgnore) require.ErrorIs(t, err, ErrAttestationCapacity) @@ -532,7 +551,7 @@ func TestPayloadAttestationServiceReportsQueuedWhenExactWorkExistsAtCapacity(t * ctrl := gomock.NewController(t) defer ctrl.Finish() - service, _, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, _, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 1, blockRoot) service.pendingAttestations.Store(pendingPayloadAttestationKeyFor(blockRoot, msg), &pendingPayloadAttestationJob{msg: msg}) @@ -545,8 +564,6 @@ func TestPayloadAttestationServiceReportsQueuedWhenExactWorkExistsAtCapacity(t * service.pendingAttestations.Store(key, &pendingPayloadAttestationJob{}) } service.pendingCount.Store(maxPendingAttestations) - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true) - err := service.ProcessMessage(context.Background(), nil, msg) require.ErrorIs(t, err, ErrIgnore) require.ErrorIs(t, err, ErrAttestationQueued) @@ -580,7 +597,7 @@ func TestPayloadAttestationServiceReferencedBlockSlotMismatch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 1, blockRoot) @@ -589,8 +606,6 @@ func TestPayloadAttestationServiceReferencedBlockSlotMismatch(t *testing.T) { Slot: 99, } - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true) - err := service.ProcessMessage(context.Background(), nil, msg) require.Error(t, err) require.True(t, errors.Is(err, ErrIgnore)) @@ -602,7 +617,7 @@ func TestPayloadAttestationServiceSuccess(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 42, blockRoot) @@ -612,9 +627,6 @@ func TestPayloadAttestationServiceSuccess(t *testing.T) { Slot: 100, } - // Mock expectations - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true) - // Process should succeed err := service.ProcessMessage(context.Background(), nil, msg) require.NoError(t, err) @@ -632,12 +644,10 @@ func TestPayloadAttestationServiceSuccess(t *testing.T) { func TestPayloadAttestationServiceRESTCommitsOnlyAfterPublish(t *testing.T) { ctrl := gomock.NewController(t) - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) root := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 42, root) fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) - require.Error(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return errors.New("publish failed") })) require.False(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42})) _, ok := service.epbsPool.PayloadAttestations.Get(pool.PayloadAttestationKey{Slot: 100, ValidatorIndex: 42}) @@ -677,14 +687,12 @@ func (f *consumeOncePayloadAttestationForkchoice) OnPayloadAttestationMessage(co func TestPayloadAttestationServiceRESTRetriesPublishWithoutRevalidating(t *testing.T) { ctrl := gomock.NewController(t) - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) root := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 42, root) fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} consumeOnce := &consumeOncePayloadAttestationForkchoice{ForkChoiceStorage: fcu} service.forkchoiceStore = consumeOnce - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) - require.Error(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return errors.New("publish failed") })) require.NoError(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return nil })) require.Equal(t, int32(1), consumeOnce.calls.Load()) @@ -693,14 +701,12 @@ func TestPayloadAttestationServiceRESTRetriesPublishWithoutRevalidating(t *testi func TestPayloadAttestationServiceP2PSatisfiesPendingRESTPublish(t *testing.T) { ctrl := gomock.NewController(t) - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) root := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 42, root) fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} consumeOnce := &consumeOncePayloadAttestationForkchoice{ForkChoiceStorage: fcu} service.forkchoiceStore = consumeOnce - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) - require.Error(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return errors.New("publish failed") })) require.NoError(t, service.ProcessMessage(t.Context(), nil, msg)) require.Equal(t, int32(1), consumeOnce.calls.Load()) @@ -709,7 +715,7 @@ func TestPayloadAttestationServiceP2PSatisfiesPendingRESTPublish(t *testing.T) { func TestPayloadAttestationServiceMismatchedP2PValidatesBehindPendingREST(t *testing.T) { ctrl := gomock.NewController(t) - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) root := common.HexToHash("0x1234") restMsg := newTestPayloadAttestationMessage(100, 42, root) p2pMsg := newTestPayloadAttestationMessage(100, 42, root) @@ -717,8 +723,6 @@ func TestPayloadAttestationServiceMismatchedP2PValidatesBehindPendingREST(t *tes fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} consumeOnce := &consumeOncePayloadAttestationForkchoice{ForkChoiceStorage: fcu} service.forkchoiceStore = consumeOnce - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) - require.Error(t, service.ProcessRESTMessage(t.Context(), restMsg, func() error { return errors.New("publish failed") })) require.ErrorIs(t, service.ProcessMessage(t.Context(), nil, p2pMsg), ErrAttestationRetryable) require.Equal(t, int32(2), consumeOnce.calls.Load()) @@ -726,7 +730,7 @@ func TestPayloadAttestationServiceMismatchedP2PValidatesBehindPendingREST(t *tes func TestPayloadAttestationServiceExactP2PJoinsRESTValidationBeforePublishFailure(t *testing.T) { ctrl := gomock.NewController(t) - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) root := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 42, root) fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} @@ -736,7 +740,6 @@ func TestPayloadAttestationServiceExactP2PJoinsRESTValidationBeforePublishFailur releaseFirst: make(chan struct{}), } service.forkchoiceStore = coordinated - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) restResult := make(chan error, 1) go func() { restResult <- service.ProcessRESTMessage(t.Context(), msg, func() error { return errors.New("publish failed") }) @@ -754,7 +757,7 @@ func TestPayloadAttestationServiceExactP2PJoinsRESTValidationBeforePublishFailur func TestPayloadAttestationServiceP2PValidatesAfterRESTValidationFailure(t *testing.T) { ctrl := gomock.NewController(t) - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) root := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 42, root) fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} @@ -765,7 +768,6 @@ func TestPayloadAttestationServiceP2PValidatesAfterRESTValidationFailure(t *test firstErr: errors.New("invalid signature"), } service.forkchoiceStore = coordinated - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) restResult := make(chan error, 1) var publishCalled atomic.Bool go func() { @@ -797,14 +799,12 @@ func waitForValidatedRESTCoordinatorRefs(t *testing.T, service *payloadAttestati func TestPayloadAttestationServiceRESTPendingRejectsDifferentIdentity(t *testing.T) { ctrl := gomock.NewController(t) - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) root := common.HexToHash("0x1234") first := newTestPayloadAttestationMessage(100, 42, root) second := newTestPayloadAttestationMessage(100, 42, root) second.Signature[0] = 1 fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) - require.Error(t, service.ProcessRESTMessage(t.Context(), first, func() error { return errors.New("publish failed") })) err := service.ProcessRESTMessage(t.Context(), second, func() error { t.Fatal("different identity published"); return nil }) require.ErrorIs(t, err, ErrAttestationConflict) @@ -812,13 +812,12 @@ func TestPayloadAttestationServiceRESTPendingRejectsDifferentIdentity(t *testing func TestPayloadAttestationServiceConcurrentRESTRetryCoalescesValidation(t *testing.T) { ctrl := gomock.NewController(t) - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) root := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 42, root) fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} consumeOnce := &consumeOncePayloadAttestationForkchoice{ForkChoiceStorage: fcu} service.forkchoiceStore = consumeOnce - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) firstPublishing := make(chan struct{}) releaseFirst := make(chan struct{}) results := make(chan error, 2) @@ -861,7 +860,7 @@ func TestPayloadAttestationServiceDifferentValidatorsSameBlock(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") msg1 := newTestPayloadAttestationMessage(100, 1, blockRoot) @@ -873,8 +872,6 @@ func TestPayloadAttestationServiceDifferentValidatorsSameBlock(t *testing.T) { } // Both should be processed (different validators) - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) - err := service.ProcessMessage(context.Background(), nil, msg1) require.NoError(t, err) @@ -915,7 +912,7 @@ func TestPayloadAttestationServicePendingSlotMismatch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, _, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, _, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 1, blockRoot) @@ -928,8 +925,7 @@ func TestPayloadAttestationServicePendingSlotMismatch(t *testing.T) { }) service.pendingCount.Store(1) - // Mock: slot 100 is no longer current - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(false) + service.now = func() time.Time { return time.Unix(101*12, 0).Add(gloasMaximumClockDisparity + time.Millisecond) } // Process pending - should remove due to slot mismatch service.processPendingAttestations(context.Background()) @@ -943,7 +939,7 @@ func TestPayloadAttestationServicePendingProcessing(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 42, blockRoot) @@ -957,7 +953,6 @@ func TestPayloadAttestationServicePendingProcessing(t *testing.T) { service.pendingCount.Store(1) // First process: slot ok, but block not available - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true) service.processPendingAttestations(context.Background()) require.Equal(t, int32(1), service.pendingCount.Load()) // Still pending @@ -967,8 +962,6 @@ func TestPayloadAttestationServicePendingProcessing(t *testing.T) { } // Second process: slot ok, block available -> should process - // ProcessMessage will be called, which calls IsSlotCurrentSlotWithMaximumClockDisparity again - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2) service.processPendingAttestations(context.Background()) require.Equal(t, int32(0), service.pendingCount.Load()) @@ -983,7 +976,7 @@ func TestPayloadAttestationServiceMultiplePendingForSameBlock(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) blockRoot := common.HexToHash("0x1234") @@ -1007,9 +1000,6 @@ func TestPayloadAttestationServiceMultiplePendingForSameBlock(t *testing.T) { Slot: 100, } - // Expect IsSlotCurrentSlotWithMaximumClockDisparity to be called for each pending + each ProcessMessage - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(4) - // Process - both should be processed service.processPendingAttestations(context.Background()) @@ -1022,14 +1012,12 @@ func TestPayloadAttestationServiceRetainsPendingRetryableValidation(t *testing.T for _, firstErr := range []error{forkchoice.ErrIgnore, context.Canceled} { t.Run(firstErr.Error(), func(t *testing.T) { ctrl := gomock.NewController(t) - service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl) + service, fcu, _ := setupPayloadAttestationService(t, ctrl) root := common.HexToHash("0x1234") msg := newTestPayloadAttestationMessage(100, 42, root) fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100} service.forkchoiceStore = &retryablePayloadAttestationForkchoice{ForkChoiceStorage: fcu, first: firstErr} service.queuePendingAttestation(root, msg) - ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(4) - service.processPendingAttestations(t.Context()) require.Equal(t, int32(1), service.pendingCount.Load()) service.processPendingAttestations(t.Context()) diff --git a/cl/phase1/stages/forward_sync.go b/cl/phase1/stages/forward_sync.go index 74972627b11..325331f601f 100644 --- a/cl/phase1/stages/forward_sync.go +++ b/cl/phase1/stages/forward_sync.go @@ -119,7 +119,7 @@ func processDownloadedBlockBatches(ctx context.Context, logger log.Logger, cfg * if block.Version() >= clparams.GloasVersion { if env, ok := envelopes[blockRoot]; ok { if err = processDownloadedGloasEnvelope(ctx, logger, cfg.forkChoice, cfg.blockCollector, block.Block, blockRoot, env, shouldInsert, shouldValidateForwardSyncPayload(cfg, shouldInsert)); err != nil { - return + return highestBlockProcessed, nil } } // Dump state periodically for restart checkpoints. diff --git a/cl/phase1/stages/gloas_payload_test.go b/cl/phase1/stages/gloas_payload_test.go index edb1f65801e..eacae7cf04c 100644 --- a/cl/phase1/stages/gloas_payload_test.go +++ b/cl/phase1/stages/gloas_payload_test.go @@ -9,8 +9,11 @@ import ( "time" "github.com/holiman/uint256" + "github.com/spf13/afero" "github.com/stretchr/testify/require" + "github.com/erigontech/erigon/cl/beacon/beacon_router_configuration" + "github.com/erigontech/erigon/cl/beacon/beaconevents" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" @@ -18,13 +21,19 @@ import ( state2 "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/phase1/execution_client" "github.com/erigontech/erigon/cl/phase1/forkchoice" + "github.com/erigontech/erigon/cl/phase1/forkchoice/fork_graph" "github.com/erigontech/erigon/cl/phase1/forkchoice/mock_services" + "github.com/erigontech/erigon/cl/phase1/forkchoice/public_keys_registry" + "github.com/erigontech/erigon/cl/pool" "github.com/erigontech/erigon/cl/utils" "github.com/erigontech/erigon/cl/utils/bls" + "github.com/erigontech/erigon/cl/validator/validator_params" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/empty" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv/dbcfg" + "github.com/erigontech/erigon/db/kv/mdbx/mdbxtest" "github.com/erigontech/erigon/execution/engineapi/engine_types" "github.com/erigontech/erigon/execution/protocol/rules/merge" "github.com/erigontech/erigon/execution/types" @@ -431,6 +440,19 @@ func (c *gloasCollectorTest) AddGloasBlock(*cltypes.BeaconBlock, *cltypes.Signed return c.err } +func (c *gloasCollectorTest) AddBlock(*cltypes.BeaconBlock) error { + c.calls++ + return c.err +} + +func (c *gloasCollectorTest) Flush(context.Context) error { + return nil +} + +func (c *gloasCollectorTest) HasBlock(uint64) bool { + return false +} + func TestProcessDownloadedGloasEnvelopeCollectorReconciliation(t *testing.T) { root := common.HexToHash("0x1234") envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)} @@ -473,6 +495,70 @@ func TestProcessDownloadedGloasEnvelopeCollectorReconciliation(t *testing.T) { }) } +func TestProcessDownloadedBlockBatchesRetainsFrontierForEnvelopeLocalFailure(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + clparams.ApplyMinimalPreset(&cfg) + cfg.GloasForkEpoch = 0 + cfg.GloasForkVersion = 0x80000038 + cfg.InitializeForkSchedule() + + block := cltypes.NewSignedBeaconBlock(&cfg, clparams.GloasVersion) + block.Block.Slot = 64 + bodyRoot, err := block.Block.Body.HashSSZ() + require.NoError(t, err) + anchorState := state2.New(&cfg) + anchorState.SetVersion(clparams.GloasVersion) + require.NoError(t, anchorState.SetSlot(block.Block.Slot)) + anchorState.SetGenesisValidatorsRoot(common.HexToHash("0x01")) + anchorState.SetLatestBlockHeader(&cltypes.BeaconBlockHeader{ + Slot: block.Block.Slot, + ProposerIndex: block.Block.ProposerIndex, + ParentRoot: block.Block.ParentRoot, + Root: block.Block.StateRoot, + BodyRoot: bodyRoot, + }) + forkGraph, err := fork_graph.NewForkGraphDisk(anchorState, nil, afero.NewMemMapFs(), beacon_router_configuration.RouterConfiguration{}) + require.NoError(t, err) + store, err := forkchoice.NewForkChoiceStore( + nil, + anchorState, + nil, + pool.NewOperationsPool(&cfg), + forkGraph, + beaconevents.NewEventEmitter(), + nil, + nil, + public_keys_registry.NewInMemoryPublicKeysRegistry(), + validator_params.NewValidatorParams(), + false, + nil, + ) + require.NoError(t, err) + blockRoot, err := block.Block.HashSSZ() + require.NoError(t, err) + collector := &gloasCollectorTest{} + stageCfg := &Cfg{ + beaconCfg: &cfg, + forkChoice: store, + indiciesDB: mdbxtest.NewTestDB(t, dbcfg.ChainDB), + blockCollector: collector, + } + + frontier, err := processDownloadedBlockBatches( + t.Context(), + log.Root(), + stageCfg, + 10, + true, + []*cltypes.SignedBeaconBlock{block}, + map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope{blockRoot: nil}, + ) + + require.NoError(t, err) + require.Equal(t, uint64(10), frontier) + require.Zero(t, collector.calls) +} + func TestGloasVerificationItemFailureOnlyStopsOnCancellation(t *testing.T) { completeBatch := true require.True(t, continueGloasVerificationAfterItemFailure(context.Background(), &completeBatch)) From d7339e6390765f15235acdb8528e9664853f2ffb Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 18:51:25 +0800 Subject: [PATCH 30/43] cl: classify unattributable sync failures --- cl/phase1/network/beacon_downloader.go | 8 +++++++- cl/phase1/network/beacon_downloader_test.go | 17 +++++++++++++++++ cl/phase1/stages/forward_sync.go | 2 +- cl/phase1/stages/gloas_payload_test.go | 5 ++++- 4 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 cl/phase1/network/beacon_downloader_test.go diff --git a/cl/phase1/network/beacon_downloader.go b/cl/phase1/network/beacon_downloader.go index 259c9c75f85..ff0ece0d40e 100644 --- a/cl/phase1/network/beacon_downloader.go +++ b/cl/phase1/network/beacon_downloader.go @@ -48,6 +48,8 @@ type ProcessFn func( newHighestSlotProcessed uint64, err error) +var ErrUnattributableProcess = errors.New("unattributable process error") + type ForwardBeaconDownloader struct { ctx context.Context highestSlotProcessed uint64 @@ -329,7 +331,7 @@ Process: var highestSlotProcessed uint64 var err error if highestSlotProcessed, err = f.process(f.highestSlotProcessed, processBlocks, envelopes); err != nil { - if pid != "http-fallback" { + if shouldBanProcessPeer(pid, err) { f.rpc.BanPeer(pid) } return @@ -340,6 +342,10 @@ Process: } } +func shouldBanProcessPeer(pid string, err error) bool { + return pid != "http-fallback" && !errors.Is(err, ErrUnattributableProcess) +} + // anyGloasBlock returns true if any block in the list is GLOAS version or later. func anyGloasBlock(blocks []*cltypes.SignedBeaconBlock) bool { for _, block := range blocks { diff --git a/cl/phase1/network/beacon_downloader_test.go b/cl/phase1/network/beacon_downloader_test.go new file mode 100644 index 00000000000..a28cb0135ed --- /dev/null +++ b/cl/phase1/network/beacon_downloader_test.go @@ -0,0 +1,17 @@ +package network + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestShouldBanProcessPeer(t *testing.T) { + processErr := errors.New("process failed") + + require.False(t, shouldBanProcessPeer("block-peer", fmt.Errorf("%w: %w", ErrUnattributableProcess, processErr))) + require.True(t, shouldBanProcessPeer("block-peer", processErr)) + require.False(t, shouldBanProcessPeer("http-fallback", processErr)) +} diff --git a/cl/phase1/stages/forward_sync.go b/cl/phase1/stages/forward_sync.go index 325331f601f..9c20529d662 100644 --- a/cl/phase1/stages/forward_sync.go +++ b/cl/phase1/stages/forward_sync.go @@ -119,7 +119,7 @@ func processDownloadedBlockBatches(ctx context.Context, logger log.Logger, cfg * if block.Version() >= clparams.GloasVersion { if env, ok := envelopes[blockRoot]; ok { if err = processDownloadedGloasEnvelope(ctx, logger, cfg.forkChoice, cfg.blockCollector, block.Block, blockRoot, env, shouldInsert, shouldValidateForwardSyncPayload(cfg, shouldInsert)); err != nil { - return highestBlockProcessed, nil + return highestBlockProcessed, fmt.Errorf("%w: %w", network2.ErrUnattributableProcess, err) } } // Dump state periodically for restart checkpoints. diff --git a/cl/phase1/stages/gloas_payload_test.go b/cl/phase1/stages/gloas_payload_test.go index eacae7cf04c..f553bab5084 100644 --- a/cl/phase1/stages/gloas_payload_test.go +++ b/cl/phase1/stages/gloas_payload_test.go @@ -24,6 +24,7 @@ import ( "github.com/erigontech/erigon/cl/phase1/forkchoice/fork_graph" "github.com/erigontech/erigon/cl/phase1/forkchoice/mock_services" "github.com/erigontech/erigon/cl/phase1/forkchoice/public_keys_registry" + network2 "github.com/erigontech/erigon/cl/phase1/network" "github.com/erigontech/erigon/cl/pool" "github.com/erigontech/erigon/cl/utils" "github.com/erigontech/erigon/cl/utils/bls" @@ -554,7 +555,9 @@ func TestProcessDownloadedBlockBatchesRetainsFrontierForEnvelopeLocalFailure(t * map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope{blockRoot: nil}, ) - require.NoError(t, err) + require.Error(t, err) + require.ErrorIs(t, err, network2.ErrUnattributableProcess) + require.ErrorContains(t, err, "nil execution payload envelope") require.Equal(t, uint64(10), frontier) require.Zero(t, collector.calls) } From 8840b42fc777fbc4376b1c4d8e18fba21539a60c Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 20:41:03 +0800 Subject: [PATCH 31/43] cl: harden Gloas ingress and publish recovery --- cl/beacon/handler/block_production.go | 54 +--- cl/beacon/handler/block_production_test.go | 303 ++++++++++++++++-- cl/cltypes/beacon_block.go | 11 +- cl/cltypes/beacon_block_test.go | 48 +++ cl/phase1/network/beacon_downloader.go | 25 +- cl/phase1/network/beacon_downloader_test.go | 99 ++++++ cl/phase1/network/services/block_service.go | 142 ++++++-- .../network/services/block_service_test.go | 188 ++++++++++- .../services/execution_payload_service.go | 91 ++++-- .../execution_payload_service_test.go | 179 +++++++++++ .../mock_services/block_service_mock.go | 36 +++ cl/phase1/network/services/types.go | 1 + 12 files changed, 1050 insertions(+), 127 deletions(-) diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 75e6ed86c81..5b96597620f 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -82,6 +82,7 @@ const ( var errBuilderNotEnabled = errors.New("builder is not enabled") var errPublishedBlockValidation = errors.New("published block validation failed") +var errPublishedBlockDataStorage = errors.New("published block data storage failed") const ( caplinClientCode = "CN" @@ -2349,13 +2350,7 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac } if validation == BlockPublishingValidationGossip { a.blockService.CommitGossipReservation(blk) - releaseGossipReservation = false - go func() { - if err := retryPublishedBlockStore(context.Background(), 3, 100*time.Millisecond, store); err != nil { - log.Error("BlockPublishing: Failed to store block and blobs", "err", err) - a.blockService.ScheduleBlockForLaterProcessing(blk) - } - }() + a.blockService.SchedulePublishedBlockForLaterProcessing(blk, store) } if blk.Version() < clparams.FuluVersion { @@ -2371,7 +2366,7 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac columnSSZ, err := column.EncodeSSZ(nil) if err != nil { a.logger.Error("Failed to encode column sidecar", "err", err) - continue + return err } subnet := das.ComputeSubnetForDataColumnSidecar(column.Index) if err := a.publishGossip(ctx, gossip.TopicNameDataColumnSidecar(subnet), columnSSZ); err != nil { @@ -2385,6 +2380,7 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac a.logger.Error("Self-build payload unavailable for validator envelope", "err", err) } } + releaseGossipReservation = false return nil } @@ -2399,26 +2395,6 @@ func (a *ApiHandler) publishGossip(ctx context.Context, topic string, data []byt return nil } -func retryPublishedBlockStore(ctx context.Context, attempts int, delay time.Duration, store func(context.Context) error) error { - var err error - for attempt := range attempts { - if err = store(ctx); err == nil { - return nil - } - if attempt+1 == attempts { - break - } - timer := time.NewTimer(delay) - select { - case <-ctx.Done(): - timer.Stop() - return ctx.Err() - case <-timer.C: - } - } - return err -} - func collectPublishedPayloadData( commitments *solid.ListSSZ[*cltypes.KZGCommitment], isGloas bool, @@ -2485,24 +2461,26 @@ func (a *ApiHandler) storeBlockAndBlobs( return err } if err := a.storeDataColumnSidecars(ctx, blockRoot, columnSidecars); err != nil { - return err + return fmt.Errorf("%w: %w", errPublishedBlockDataStorage, err) } if block.Version() < clparams.FuluVersion { if err := a.blobStoage.WriteBlobSidecars(ctx, blockRoot, sidecars); err != nil { - return err + return fmt.Errorf("%w: %w", errPublishedBlockDataStorage, err) } } currentSlot := a.ethClock.GetCurrentSlot() a.forkchoiceStore.OnTick(a.ethClock.GenesisTime() + currentSlot*a.beaconChainCfg.SecondsPerSlot) - var blockErr error - if rejectEquivocation { - blockErr = a.forkchoiceStore.OnBlockWithEquivocationCheck(ctx, block, true, true, false) - } else { - blockErr = a.forkchoiceStore.OnBlock(ctx, block, true, true, false) - } - if blockErr != nil { - return fmt.Errorf("%w: %w", errPublishedBlockValidation, blockErr) + if _, exists := a.forkchoiceStore.GetHeader(blockRoot); !exists { + var blockErr error + if rejectEquivocation { + blockErr = a.forkchoiceStore.OnBlockWithEquivocationCheck(ctx, block, true, true, false) + } else { + blockErr = a.forkchoiceStore.OnBlock(ctx, block, true, true, false) + } + if blockErr != nil { + return fmt.Errorf("%w: %w", errPublishedBlockValidation, blockErr) + } } // Cache the execution payload body before writing to DB so the beacon API diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index aa9439e9455..0f211b8fc07 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -48,6 +48,7 @@ import ( "github.com/erigontech/erigon/cl/phase1/execution_client" "github.com/erigontech/erigon/cl/phase1/forkchoice" gossip_mock "github.com/erigontech/erigon/cl/phase1/network/gossip/mock_services" + clservices "github.com/erigontech/erigon/cl/phase1/network/services" network_services_mock "github.com/erigontech/erigon/cl/phase1/network/services/mock_services" serviceinterface "github.com/erigontech/erigon/cl/phase1/network/services/service_interface" "github.com/erigontech/erigon/cl/pool" @@ -56,6 +57,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/engineapi/engine_helpers" "github.com/erigontech/erigon/execution/engineapi/engine_types" @@ -70,6 +72,135 @@ import ( var _ serviceinterface.Service[*cltypes.SignedExecutionPayloadBid] = acceptingExecutionPayloadBidService{} +type publishingBlockKey struct { + proposer uint64 + slot uint64 +} + +type publishingSeenBlock struct { + signedRoot common.Hash + replayAllowed bool +} + +type replayableBlockService struct { + *network_services_mock.MockBlockService + mu sync.Mutex + pending map[publishingBlockKey]common.Hash + seen map[publishingBlockKey]publishingSeenBlock + scheduled chan struct{} +} + +func (s *replayableBlockService) ValidateGossip(_ context.Context, block *cltypes.SignedBeaconBlock) error { + root, err := block.HashSSZ() + if err != nil { + return err + } + key := publishingBlockKey{proposer: block.Block.ProposerIndex, slot: block.Block.Slot} + s.mu.Lock() + defer s.mu.Unlock() + if seen, ok := s.seen[key]; ok { + if seen.signedRoot == common.Hash(root) && seen.replayAllowed { + seen.replayAllowed = false + s.seen[key] = seen + return nil + } + return fmt.Errorf("%w: block already seen for proposer and slot", clservices.ErrIgnore) + } + if _, ok := s.pending[key]; ok { + return fmt.Errorf("%w: block reservation pending for proposer and slot", clservices.ErrIgnore) + } + s.pending[key] = common.Hash(root) + return nil +} + +func (s *replayableBlockService) CommitGossipReservation(block *cltypes.SignedBeaconBlock) { + key := publishingBlockKey{proposer: block.Block.ProposerIndex, slot: block.Block.Slot} + s.mu.Lock() + defer s.mu.Unlock() + if root, ok := s.pending[key]; ok { + s.seen[key] = publishingSeenBlock{signedRoot: root} + delete(s.pending, key) + } +} + +func (s *replayableBlockService) ReleaseGossipReservation(block *cltypes.SignedBeaconBlock) { + root, err := block.HashSSZ() + if err != nil { + return + } + key := publishingBlockKey{proposer: block.Block.ProposerIndex, slot: block.Block.Slot} + s.mu.Lock() + if _, ok := s.pending[key]; ok { + delete(s.pending, key) + } else if seen, ok := s.seen[key]; ok && seen.signedRoot == common.Hash(root) { + seen.replayAllowed = true + s.seen[key] = seen + } + s.mu.Unlock() +} + +func (s *replayableBlockService) ScheduleBlockForLaterProcessing(*cltypes.SignedBeaconBlock) { + s.scheduled <- struct{}{} +} + +func (s *replayableBlockService) SchedulePublishedBlockForLaterProcessing(*cltypes.SignedBeaconBlock, func(context.Context) error) { + s.scheduled <- struct{}{} +} + +type failFirstSidecarGossip struct { + *gossip_mock.MockGossip + mu sync.Mutex + blockPublishes int + failBlockAt int + sidecarAttempts int +} + +type unavailableUpdateDB struct { + kv.RwDB +} + +func (db unavailableUpdateDB) Update(context.Context, func(kv.RwTx) error) error { + return errors.New("database unavailable") +} + +type installingForkchoice struct { + forkchoice.ForkChoiceStorage + headers map[common.Hash]*cltypes.BeaconBlockHeader + onBlockCalls int +} + +func (f *installingForkchoice) GetHeader(root common.Hash) (*cltypes.BeaconBlockHeader, bool) { + header, ok := f.headers[root] + return header, ok +} + +func (f *installingForkchoice) OnBlock(_ context.Context, block *cltypes.SignedBeaconBlock, _, _, _ bool) error { + root, err := block.Block.HashSSZ() + if err != nil { + return err + } + f.headers[root] = block.SignedBeaconBlockHeader().Header.Copy() + f.onBlockCalls++ + return nil +} + +func (g *failFirstSidecarGossip) Publish(_ context.Context, topic string, _ []byte) error { + g.mu.Lock() + defer g.mu.Unlock() + if topic == gossip.TopicNameBeaconBlock { + g.blockPublishes++ + if g.blockPublishes == g.failBlockAt { + return errors.New("block unavailable") + } + return nil + } + g.sidecarAttempts++ + if g.sidecarAttempts == 1 { + return errors.New("sidecar unavailable") + } + return nil +} + type acceptingExecutionPayloadBidService struct{} func (acceptingExecutionPayloadBidService) Names() []string { return nil } @@ -107,20 +238,6 @@ func TestStoreDataColumnSidecarsRejectsInvalidInput(t *testing.T) { )) } -func TestRetryPublishedBlockStoreRecoversFromTransientFailure(t *testing.T) { - calls := 0 - err := retryPublishedBlockStore(t.Context(), 3, time.Millisecond, func(context.Context) error { - calls++ - if calls == 1 { - return errors.New("transient storage failure") - } - return nil - }) - - require.NoError(t, err) - require.Equal(t, 2, calls) -} - func TestBlockBuilderWindowPreGloas(t *testing.T) { cfg := &clparams.BeaconChainConfig{ SecondsPerSlot: 12, @@ -933,7 +1050,7 @@ func TestBroadcastBlockDoesNotStoreBeforeBlockPublication(t *testing.T) { } } -func TestBroadcastBlockKeepsGossipReservationAfterBlockPublication(t *testing.T) { +func TestBroadcastBlockSchedulesFullRecoveryAfterBlobStorageFailure(t *testing.T) { ctrl := gomock.NewController(t) _, blocks, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) block := blocks[1] @@ -948,11 +1065,17 @@ func TestBroadcastBlockKeepsGossipReservationAfterBlockPublication(t *testing.T) blockService := network_services_mock.NewMockBlockService(ctrl) blockService.EXPECT().ValidateGossip(gomock.Any(), block).Return(nil) blockService.EXPECT().CommitGossipReservation(block) - scheduled := make(chan struct{}) - blockService.EXPECT().ScheduleBlockForLaterProcessing(block).Do(func(*cltypes.SignedBeaconBlock) { close(scheduled) }) + blockService.EXPECT().ReleaseGossipReservation(block) + blockService.EXPECT().ScheduleBlockForLaterProcessing(block).Times(0) + scheduledStore := make(chan func(context.Context) error, 1) + blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(block, gomock.Any()).Do(func(_ *cltypes.SignedBeaconBlock, store func(context.Context) error) { + scheduledStore <- store + }) handler.blockService = blockService blobStorage := blob_storage_mock.NewMockBlobStorage(ctrl) - blobStorage.EXPECT().WriteBlobSidecars(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("storage unavailable")).AnyTimes() + blobStorage.EXPECT().WriteBlobSidecars(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(context.Context, common.Hash, []*cltypes.BlobSidecar) error { + return errors.New("storage unavailable") + }).Times(1) handler.blobStoage = blobStorage gossipManager := gossip_mock.NewMockGossip(ctrl) gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, gomock.Any()).Return(nil) @@ -962,9 +1085,149 @@ func TestBroadcastBlockKeepsGossipReservationAfterBlockPublication(t *testing.T) err := handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip) require.ErrorContains(t, err, "sidecar unavailable") select { - case <-scheduled: + case store := <-scheduledStore: + require.ErrorContains(t, store(t.Context()), "storage unavailable") + case <-time.After(time.Second): + t.Fatal("full published-block storage was not scheduled") + } +} + +func TestBroadcastBlockSchedulesRecoveryAfterSidecarsAndForkchoiceSucceed(t *testing.T) { + ctrl := gomock.NewController(t) + _, blocks, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + block := blocks[1] + commitment := &cltypes.KZGCommitment{1} + block.Block.Body.BlobKzgCommitments = solid.NewStaticListSSZ[*cltypes.KZGCommitment](1, 48) + block.Block.Body.BlobKzgCommitments.Append(commitment) + handler.blobBundles.Add(common.Bytes48(*commitment), BlobBundle{ + Commitment: common.Bytes48(*commitment), + Blob: &cltypes.Blob{}, + KzgProofs: []common.Bytes48{{1}}, + }) + handler.indiciesDB = unavailableUpdateDB{RwDB: handler.indiciesDB} + fcu.OnTickFn = func(uint64) {} + + blockService := network_services_mock.NewMockBlockService(ctrl) + blockService.EXPECT().ValidateGossip(gomock.Any(), block).Return(nil) + blockService.EXPECT().CommitGossipReservation(block) + scheduledStore := make(chan func(context.Context) error, 1) + blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(block, gomock.Any()).Do(func(_ *cltypes.SignedBeaconBlock, store func(context.Context) error) { scheduledStore <- store }) + handler.blockService = blockService + gossipManager := gossip_mock.NewMockGossip(ctrl) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, gomock.Any()).Return(nil) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBlobSidecar(uint64(0)), gomock.Any()).Return(nil) + handler.gossipManager = gossipManager + + require.NoError(t, handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip)) + select { + case store := <-scheduledStore: + require.ErrorContains(t, store(t.Context()), "database unavailable") case <-time.After(time.Second): - t.Fatal("block was not scheduled after terminal local storage failure") + t.Fatal("post-forkchoice database failure was not scheduled") + } + root, err := block.Block.HashSSZ() + require.NoError(t, err) + sidecars, found, err := handler.blobStoage.ReadBlobSidecars(t.Context(), block.Block.Slot, root) + require.NoError(t, err) + require.True(t, found) + require.Len(t, sidecars, 1) +} + +func TestStoreBlockAndBlobsDoesNotRepeatForkchoiceAfterDatabaseFailure(t *testing.T) { + _, blocks, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + block := blocks[1] + fcu.OnTickFn = func(uint64) {} + installing := &installingForkchoice{ + ForkChoiceStorage: fcu, + headers: make(map[common.Hash]*cltypes.BeaconBlockHeader), + } + handler.forkchoiceStore = installing + handler.indiciesDB = unavailableUpdateDB{RwDB: handler.indiciesDB} + + for range 2 { + err := handler.storeBlockAndBlobs(t.Context(), block, nil, nil, false) + require.ErrorContains(t, err, "database unavailable") + } + require.Equal(t, 1, installing.onBlockCalls) +} + +func TestBroadcastBlockExactReplayCompletesMissingDataSidecars(t *testing.T) { + for _, version := range []clparams.StateVersion{clparams.DenebVersion, clparams.FuluVersion} { + t.Run(version.String(), func(t *testing.T) { + ctrl := gomock.NewController(t) + cfg := clparams.MainnetBeaconConfig + if clparams.GetBeaconConfig() == nil { + clparams.InitGlobalStaticConfig(&cfg, &clparams.CaplinConfig{}) + } + block := cltypes.NewSignedBeaconBlock(&cfg, version) + block.Block.Slot = cfg.SlotsPerEpoch + commitment := &cltypes.KZGCommitment{1} + block.Block.Body.BlobKzgCommitments.Append(commitment) + blobBundles, err := lru.New[common.Bytes48, BlobBundle]("test-replay-blobs", 1) + require.NoError(t, err) + proofs := []common.Bytes48{{1}} + if version >= clparams.FuluVersion { + proofs = make([]common.Bytes48, cfg.NumberOfColumns) + } + blobBundles.Add(common.Bytes48(*commitment), BlobBundle{ + Commitment: common.Bytes48(*commitment), + Blob: &cltypes.Blob{}, + KzgProofs: proofs, + }) + + blockService := &replayableBlockService{ + MockBlockService: network_services_mock.NewMockBlockService(ctrl), + pending: make(map[publishingBlockKey]common.Hash), + seen: make(map[publishingBlockKey]publishingSeenBlock), + scheduled: make(chan struct{}, 2), + } + gossipManager := &failFirstSidecarGossip{MockGossip: gossip_mock.NewMockGossip(ctrl)} + handler := &ApiHandler{ + beaconChainCfg: &cfg, + blockService: blockService, + blobBundles: blobBundles, + gossipManager: gossipManager, + logger: log.Root(), + } + if version < clparams.FuluVersion { + blobStorage := blob_storage_mock.NewMockBlobStorage(ctrl) + blobStorage.EXPECT().WriteBlobSidecars(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("storage unavailable")).AnyTimes() + handler.blobStoage = blobStorage + } else { + columnStorage := blob_storage_mock.NewMockDataColumnStorage(ctrl) + columnStorage.EXPECT().WriteColumnSidecars(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("storage unavailable")).AnyTimes() + handler.columnStorage = columnStorage + } + + err = handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip) + require.ErrorContains(t, err, "sidecar unavailable") + gossipManager.mu.Lock() + gossipManager.failBlockAt = 2 + gossipManager.mu.Unlock() + err = handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip) + require.ErrorContains(t, err, "block unavailable") + require.NoError(t, handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip)) + err = handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip) + require.ErrorIs(t, err, errPublishedBlockValidation) + require.ErrorIs(t, err, clservices.ErrIgnore) + + block.Block.StateRoot[0] ^= 1 + err = handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip) + require.ErrorIs(t, err, errPublishedBlockValidation) + require.ErrorIs(t, err, clservices.ErrIgnore) + + for range 2 { + select { + case <-blockService.scheduled: + case <-time.After(time.Second): + t.Fatal("sidecar storage failure did not schedule full recovery") + } + } + gossipManager.mu.Lock() + require.Equal(t, 3, gossipManager.blockPublishes) + require.GreaterOrEqual(t, gossipManager.sidecarAttempts, 2) + gossipManager.mu.Unlock() + }) } } diff --git a/cl/cltypes/beacon_block.go b/cl/cltypes/beacon_block.go index 5a02ca2dd9f..e2362aaca43 100644 --- a/cl/cltypes/beacon_block.go +++ b/cl/cltypes/beacon_block.go @@ -766,6 +766,10 @@ func (b *BeaconBody) KzgCommitmentsInclusionProof() ([][32]byte, error) { func (b *BeaconBody) UnmarshalJSON(buf []byte) error { limits := beaconBodyLimitsForConfig(b.beaconCfg, b.Version) + ptcSize := clparams.MaxPtcSize + if b.beaconCfg != nil && b.beaconCfg.PtcSize > 0 { + ptcSize = b.beaconCfg.PtcSize + } var tmp struct { RandaoReveal common.Bytes96 `json:"randao_reveal"` @@ -811,10 +815,6 @@ func (b *BeaconBody) UnmarshalJSON(buf []byte) error { BlobKzgCommitments: *solid.NewStaticProgressiveListSSZ[*KZGCommitment](maxBlobCommitmentsForConfig(b.beaconCfg), 48), }, } - ptcSize := clparams.MaxPtcSize - if b.beaconCfg != nil && b.beaconCfg.PtcSize > 0 { - ptcSize = b.beaconCfg.PtcSize - } tmp.PayloadAttestations = solid.NewStaticProgressiveListSSZ[*PayloadAttestation](maxPayloadAttestationsForConfig(b.beaconCfg), PayloadAttestationSSZSizeWithPtcSize(ptcSize)) tmp.ParentExecutionRequests = NewExecutionRequestsWithVersion(b.beaconCfg, b.Version) } @@ -841,6 +841,9 @@ func (b *BeaconBody) UnmarshalJSON(buf []byte) error { if attestation == nil || attestation.AggregationBits == nil || attestation.Data == nil { return fmt.Errorf("payload attestation %d is incomplete", i) } + if err := attestation.AggregationBits.ValidateSize(int(ptcSize)); err != nil { + return fmt.Errorf("payload attestation %d aggregation bits: %w", i, err) + } return nil }); err != nil { return err diff --git a/cl/cltypes/beacon_block_test.go b/cl/cltypes/beacon_block_test.go index 51996450c40..5f29f2760d5 100644 --- a/cl/cltypes/beacon_block_test.go +++ b/cl/cltypes/beacon_block_test.go @@ -19,6 +19,7 @@ package cltypes import ( _ "embed" "encoding/json" + "fmt" "math" "testing" @@ -678,3 +679,50 @@ func TestBeaconBodyGloasProgressiveLimitsUseConfig(t *testing.T) { require.NoError(t, body.ProposerSlashings.DecodeSSZ(make([]byte, 33*416), int(clparams.GloasVersion))) } } + +func TestSignedBeaconBlockGloasJSONValidatesPayloadAttestationBits(t *testing.T) { + for _, test := range []struct { + name string + ptcSize uint64 + bits string + wantErr string + }{ + {name: "configured width", ptcSize: 16, bits: "0x0000"}, + {name: "short width", ptcSize: 16, bits: "0x00", wantErr: "invalid bitvector byte length"}, + {name: "non-byte-aligned width", ptcSize: 10, bits: "0x0003"}, + {name: "non-byte-aligned unused bits", ptcSize: 10, bits: "0x0004", wantErr: "invalid bitvector unused bits"}, + } { + t.Run(test.name, func(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + cfg.PtcSize = test.ptcSize + block := NewSignedBeaconBlock(&cfg, clparams.GloasVersion) + input := []byte(fmt.Sprintf(`{"message":{"body":{"payload_attestations":[{"aggregation_bits":%q,"data":{}}]}}}`, test.bits)) + + err := json.Unmarshal(input, block) + if test.wantErr != "" { + require.ErrorContains(t, err, test.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestSignedBeaconBlockJSONPayloadAttestationValidationVersionBoundary(t *testing.T) { + for _, test := range []struct { + name string + version clparams.StateVersion + input string + }{ + {name: "Fulu body", version: clparams.FuluVersion, input: `{"message":{"body":{}}}`}, + {name: "Gloas omitted attestations", version: clparams.GloasVersion, input: `{"message":{"body":{}}}`}, + {name: "Gloas empty attestations", version: clparams.GloasVersion, input: `{"message":{"body":{"payload_attestations":[]}}}`}, + } { + t.Run(test.name, func(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + block := NewSignedBeaconBlock(&cfg, test.version) + + require.NoError(t, json.Unmarshal([]byte(test.input), block)) + }) + } +} diff --git a/cl/phase1/network/beacon_downloader.go b/cl/phase1/network/beacon_downloader.go index ff0ece0d40e..443e3a703cb 100644 --- a/cl/phase1/network/beacon_downloader.go +++ b/cl/phase1/network/beacon_downloader.go @@ -283,9 +283,6 @@ Process: // batch boundary has its envelope skipped, and the next batch's first // block fails with ErrParentEnvelopePending. processCount := min(int(count), len(processBlocks)-1) - if processCount < 1 { - processCount = len(processBlocks) // single block: process it (best-effort) - } fullRoots := determineFullGloasRoots(processBlocks, processCount) processBlocks = processBlocks[:processCount] if len(fullRoots) > 0 { @@ -319,6 +316,12 @@ Process: "batchBlocks", len(processBlocks), "firstSlot", processBlocks[0].Block.Slot, "lastSlot", processBlocks[len(processBlocks)-1].Block.Slot) + retained := retainBlocksBeforeMissingGloasEnvelope(processBlocks, fullRoots, envelopes) + if len(retained) < len(processBlocks) { + log.Debug("[ForwardBeaconDownloader] retaining frontier before missing GLOAS envelope", + "retainedBlocks", len(retained), "batchBlocks", len(processBlocks)) + processBlocks = retained + } } } else if uint64(len(processBlocks)) > count { // Non-GLOAS: still trim the extra lookahead block. @@ -342,6 +345,22 @@ Process: } } +func retainBlocksBeforeMissingGloasEnvelope(blocks []*cltypes.SignedBeaconBlock, fullRoots [][32]byte, envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) []*cltypes.SignedBeaconBlock { + for _, root := range fullRoots { + if envelopes[common.Hash(root)] != nil { + continue + } + for i, block := range blocks { + blockRoot, err := block.Block.HashSSZ() + if err != nil || blockRoot == root { + return blocks[:i] + } + } + return nil + } + return blocks +} + func shouldBanProcessPeer(pid string, err error) bool { return pid != "http-fallback" && !errors.Is(err, ErrUnattributableProcess) } diff --git a/cl/phase1/network/beacon_downloader_test.go b/cl/phase1/network/beacon_downloader_test.go index a28cb0135ed..ba2d782d066 100644 --- a/cl/phase1/network/beacon_downloader_test.go +++ b/cl/phase1/network/beacon_downloader_test.go @@ -1,11 +1,19 @@ package network import ( + "context" "errors" "fmt" + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/cl/clparams" + "github.com/erigontech/erigon/cl/cltypes" + "github.com/erigontech/erigon/common" ) func TestShouldBanProcessPeer(t *testing.T) { @@ -15,3 +23,94 @@ func TestShouldBanProcessPeer(t *testing.T) { require.True(t, shouldBanProcessPeer("block-peer", processErr)) require.False(t, shouldBanProcessPeer("http-fallback", processErr)) } + +func TestForwardBeaconDownloaderRetainsFrontierWhenHTTPFullEnvelopeIsMissing(t *testing.T) { + first := makeGloasBlock(10, hash(0xaa), common.Hash{}) + lookahead := makeGloasBlock(11, hash(0xbb), hash(0xaa)) + firstEncoded, err := first.EncodeSSZ(nil) + require.NoError(t, err) + lookaheadEncoded, err := lookahead.EncodeSSZ(nil) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Eth-Consensus-Version", "gloas") + switch { + case strings.HasSuffix(r.URL.Path, "/beacon/blocks/10"): + _, _ = w.Write(firstEncoded) + case strings.HasSuffix(r.URL.Path, "/beacon/blocks/11"): + _, _ = w.Write(lookaheadEncoded) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig) + downloader.SetHighestProcessedSlot(9) + downloader.SetHTTPFallbackURL(server.URL) + downloader.httpPreferred.Store(true) + processed := 0 + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + processed += len(blocks) + if len(blocks) == 0 { + return highest, nil + } + return blocks[len(blocks)-1].Block.Slot, nil + }) + + downloader.RequestMore(t.Context()) + + require.Zero(t, processed) + require.Equal(t, uint64(9), downloader.GetHighestProcessedSlot()) +} + +func TestForwardBeaconDownloaderRetainsSingleHTTPGloasBlockUntilLookahead(t *testing.T) { + block := makeGloasBlock(10, hash(0xaa), common.Hash{}) + encoded, err := block.EncodeSSZ(nil) + require.NoError(t, err) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/beacon/blocks/10") { + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encoded) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig) + downloader.SetHighestProcessedSlot(9) + downloader.SetHTTPFallbackURL(server.URL) + downloader.httpPreferred.Store(true) + processed := 0 + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + processed += len(blocks) + if len(blocks) == 0 { + return highest, nil + } + return blocks[len(blocks)-1].Block.Slot, nil + }) + + downloader.RequestMore(t.Context()) + + require.Zero(t, processed) + require.Equal(t, uint64(9), downloader.GetHighestProcessedSlot()) +} + +func TestRetainBlocksBeforeMissingGloasEnvelopeKeepsCompletePrefix(t *testing.T) { + first := makeGloasBlock(10, hash(0xaa), common.Hash{}) + second := makeGloasBlock(11, hash(0xbb), hash(0xaa)) + lookahead := makeGloasBlock(12, hash(0xcc), hash(0xbb)) + blocks := []*cltypes.SignedBeaconBlock{first, second} + fullRoots := determineFullGloasRoots([]*cltypes.SignedBeaconBlock{first, second, lookahead}, len(blocks)) + firstRoot, err := first.Block.HashSSZ() + require.NoError(t, err) + + retained := retainBlocksBeforeMissingGloasEnvelope( + blocks, + fullRoots, + map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope{firstRoot: {}}, + ) + + require.Equal(t, blocks[:1], retained) +} diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index 8c17eafa98f..86656ad271c 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -53,15 +53,22 @@ type proposerIndexAndSlot struct { type blockJob struct { block *cltypes.SignedBeaconBlock + store func(context.Context) error creationTime time.Time } type blockReservation struct { pending chan struct{} + root common.Hash version uint64 validators uint64 } +type seenBlock struct { + signedRoot common.Hash + replayAllowed bool +} + type blockService struct { forkchoiceStore forkchoice.ForkChoiceStorage syncedData *synced_data.SyncedDataManager @@ -69,7 +76,7 @@ type blockService struct { beaconCfg *clparams.BeaconChainConfig // reference: https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#beacon_block - seenBlocksCache *lru.Cache[proposerIndexAndSlot, struct{}] + seenBlocksCache *lru.Cache[proposerIndexAndSlot, seenBlock] reservations map[proposerIndexAndSlot]*blockReservation seenBlocksMu sync.Mutex @@ -90,7 +97,7 @@ func NewBlockService( beaconCfg *clparams.BeaconChainConfig, emitter *beaconevents.EventEmitter, ) BlockService { - seenBlocksCache, err := lru.New[proposerIndexAndSlot, struct{}]("seenblocks", seenBlockCacheSize) + seenBlocksCache, err := lru.New[proposerIndexAndSlot, seenBlock]("seenblocks", seenBlockCacheSize) if err != nil { panic(err) } @@ -147,10 +154,27 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp } func (b *blockService) ValidateGossip(ctx context.Context, msg *cltypes.SignedBeaconBlock) error { + if msg == nil || msg.Block == nil || msg.Block.Body == nil { + return errors.New("missing beacon block") + } + root, err := msg.HashSSZ() + if err != nil { + return err + } + key := blockGossipKey(msg) + b.seenBlocksMu.Lock() + claimed, claimErr := b.claimGossipReplayLocked(key, common.Hash(root)) + b.seenBlocksMu.Unlock() + if claimErr != nil { + return claimErr + } + if claimed { + return nil + } if err := b.validateGossip(ctx, msg, nil); err != nil { return err } - return b.reserveGossipKey(blockGossipKey(msg)) + return b.reserveGossipKey(key, common.Hash(root)) } func (b *blockService) CommitGossipReservation(msg *cltypes.SignedBeaconBlock) { @@ -164,11 +188,19 @@ func (b *blockService) ReleaseGossipReservation(msg *cltypes.SignedBeaconBlock) if msg == nil || msg.Block == nil { return } - b.releaseGossipKey(blockGossipKey(msg)) + root, err := msg.HashSSZ() + if err != nil { + return + } + b.releaseGossipKey(blockGossipKey(msg), common.Hash(root)) } func (b *blockService) validateFirstGossip(ctx context.Context, msg *cltypes.SignedBeaconBlock, schedule func(), waitForPending bool) error { key := blockGossipKey(msg) + root, err := msg.HashSSZ() + if err != nil { + return err + } for { if err := ctx.Err(); err != nil { return fmt.Errorf("%w: block validation canceled: %w", ErrIgnore, err) @@ -230,18 +262,25 @@ func (b *blockService) validateFirstGossip(ctx context.Context, msg *cltypes.Sig b.seenBlocksMu.Unlock() continue } - b.seenBlocksCache.Add(key, struct{}{}) + b.seenBlocksCache.Add(key, seenBlock{signedRoot: common.Hash(root)}) b.cleanupReservationLocked(key, reservation) b.seenBlocksMu.Unlock() return nil } } -func (b *blockService) reserveGossipKey(key proposerIndexAndSlot) error { +func (b *blockService) reserveGossipKey(key proposerIndexAndSlot, root common.Hash) error { b.seenBlocksMu.Lock() defer b.seenBlocksMu.Unlock() reservation := b.reservations[key] - if b.seenBlocksCache.Contains(key) || reservation != nil && reservation.pending != nil { + claimed, err := b.claimGossipReplayLocked(key, root) + if err != nil { + return err + } + if claimed { + return nil + } + if reservation != nil && reservation.pending != nil { return fmt.Errorf("%w: block already seen for proposer and slot", ErrIgnore) } if reservation == nil { @@ -249,6 +288,7 @@ func (b *blockService) reserveGossipKey(key proposerIndexAndSlot) error { b.reservations[key] = reservation } reservation.pending = make(chan struct{}) + reservation.root = root reservation.version++ return nil } @@ -263,16 +303,21 @@ func (b *blockService) commitGossipKey(key proposerIndexAndSlot) { done := reservation.pending reservation.pending = nil reservation.version++ - b.seenBlocksCache.Add(key, struct{}{}) + b.seenBlocksCache.Add(key, seenBlock{signedRoot: reservation.root}) close(done) b.cleanupReservationLocked(key, reservation) } -func (b *blockService) releaseGossipKey(key proposerIndexAndSlot) { +func (b *blockService) releaseGossipKey(key proposerIndexAndSlot, root common.Hash) { b.seenBlocksMu.Lock() defer b.seenBlocksMu.Unlock() reservation := b.reservations[key] if reservation == nil || reservation.pending == nil { + seen, ok := b.seenBlocksCache.Get(key) + if ok && seen.signedRoot == root { + seen.replayAllowed = true + b.seenBlocksCache.Add(key, seen) + } return } done := reservation.pending @@ -282,6 +327,19 @@ func (b *blockService) releaseGossipKey(key proposerIndexAndSlot) { b.cleanupReservationLocked(key, reservation) } +func (b *blockService) claimGossipReplayLocked(key proposerIndexAndSlot, root common.Hash) (bool, error) { + seen, ok := b.seenBlocksCache.Get(key) + if !ok { + return false, nil + } + if seen.signedRoot != root || !seen.replayAllowed { + return false, fmt.Errorf("%w: block already seen for proposer and slot", ErrIgnore) + } + seen.replayAllowed = false + b.seenBlocksCache.Add(key, seen) + return true, nil +} + func (b *blockService) cleanupReservationLocked(key proposerIndexAndSlot, reservation *blockReservation) { if reservation.pending == nil && reservation.validators == 0 && b.reservations[key] == reservation { delete(b.reservations, key) @@ -509,6 +567,14 @@ func (b *blockService) publishBlockGossipEvent(block *cltypes.SignedBeaconBlock) // ScheduleBlockForLaterProcessing schedules a block for later processing. func (b *blockService) ScheduleBlockForLaterProcessing(block *cltypes.SignedBeaconBlock) { + b.scheduleBlockForLaterProcessing(block, nil) +} + +func (b *blockService) SchedulePublishedBlockForLaterProcessing(block *cltypes.SignedBeaconBlock, store func(context.Context) error) { + b.scheduleBlockForLaterProcessing(block, store) +} + +func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeaconBlock, store func(context.Context) error) { // [Modified in Gloas:EIP7732] ExecutionPayload is not in block.body for GLOAS var blockNum uint64 if block.Block.Body.ExecutionPayload != nil { @@ -521,10 +587,24 @@ func (b *blockService) ScheduleBlockForLaterProcessing(block *cltypes.SignedBeac return } - b.blocksScheduledForLaterExecution.Store(blockRoot, &blockJob{ + job := &blockJob{ block: block, + store: store, creationTime: time.Now(), - }) + } + for { + existingValue, loaded := b.blocksScheduledForLaterExecution.LoadOrStore(blockRoot, job) + if !loaded { + return + } + existing := existingValue.(*blockJob) + if existing.store != nil || store == nil { + return + } + if b.blocksScheduledForLaterExecution.CompareAndSwap(blockRoot, existing, job) { + return + } + } } // processAndStoreBlock processes and stores a block @@ -534,9 +614,7 @@ func (b *blockService) processAndStoreBlock(ctx context.Context, block *cltypes. return err } - if _, ok := b.forkchoiceStore.GetHeader(blockRoot); ok { - return nil - } + _, headerExists := b.forkchoiceStore.GetHeader(blockRoot) if err := b.db.Update(ctx, func(tx kv.RwTx) error { return beacon_indicies.WriteBeaconBlockAndIndicies(ctx, tx, block, false) @@ -544,10 +622,12 @@ func (b *blockService) processAndStoreBlock(ctx context.Context, block *cltypes. return err } - if err := b.forkchoiceStore.OnBlock(ctx, block, true, true, true); err != nil { - return err + if !headerExists { + if err := b.forkchoiceStore.OnBlock(ctx, block, true, true, true); err != nil { + return err + } + go b.importBlockOperations(block) } - go b.importBlockOperations(block) if err := b.db.Update(ctx, func(tx kv.RwTx) error { return beacon_indicies.WriteHighestFinalized(tx, b.forkchoiceStore.FinalizedSlot()) }); err != nil { @@ -592,18 +672,24 @@ func (b *blockService) loop(ctx context.Context) { case <-ticker.C: } b.blocksScheduledForLaterExecution.Range(func(key, value any) bool { - blockJob := value.(*blockJob) - // check if it has expired - if time.Since(blockJob.creationTime) > blockJobExpiry { - b.blocksScheduledForLaterExecution.Delete(key.([32]byte)) - return true - } - if err := b.processAndStoreBlock(ctx, blockJob.block); err != nil { - log.Trace("Failed to process and store block", "block", blockJob.block, "error", err) - return true - } - b.blocksScheduledForLaterExecution.Delete(key.([32]byte)) + b.processScheduledBlock(ctx, key.([32]byte), value.(*blockJob), time.Now()) return true }) } } + +func (b *blockService) processScheduledBlock(ctx context.Context, key [32]byte, job *blockJob, now time.Time) { + if now.Sub(job.creationTime) > blockJobExpiry { + b.blocksScheduledForLaterExecution.CompareAndDelete(key, job) + return + } + store := job.store + if store == nil { + store = func(ctx context.Context) error { return b.processAndStoreBlock(ctx, job.block) } + } + if err := store(ctx); err != nil { + log.Trace("Failed to process and store block", "block", job.block, "error", err) + return + } + b.blocksScheduledForLaterExecution.CompareAndDelete(key, job) +} diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index 1de3a390f99..1d5a05bb22a 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -34,6 +34,7 @@ import ( "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" "github.com/erigontech/erigon/cl/fork" + "github.com/erigontech/erigon/cl/persistence/beacon_indicies" "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/phase1/execution_client" "github.com/erigontech/erigon/cl/phase1/forkchoice" @@ -43,7 +44,9 @@ import ( "github.com/erigontech/erigon/cl/utils/eth_clock" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/dbcfg" + "github.com/erigontech/erigon/db/kv/dbutils" "github.com/erigontech/erigon/db/kv/mdbx/mdbxtest" ) @@ -52,6 +55,19 @@ type attesterSlashingErrorStore struct { err error } +type failFirstUpdateDB struct { + kv.RwDB + failed bool +} + +func (db *failFirstUpdateDB) Update(ctx context.Context, f func(kv.RwTx) error) error { + if !db.failed { + db.failed = true + return errors.New("database unavailable") + } + return db.RwDB.Update(ctx, f) +} + func (s attesterSlashingErrorStore) OnAttesterSlashing(*cltypes.AttesterSlashing, bool) error { return s.err } @@ -334,6 +350,174 @@ func TestBlockServiceGossipReservationCanBeReleased(t *testing.T) { require.NoError(t, service.ValidateGossip(t.Context(), child)) } +func TestBlockServiceCommittedReservationAllowsExactRESTReplayOnly(t *testing.T) { + service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated + require.NoError(t, service.ValidateGossip(t.Context(), child)) + service.CommitGossipReservation(child) + require.ErrorIs(t, service.ValidateGossip(t.Context(), child), ErrIgnore) + service.ReleaseGossipReservation(child) + + child.Block.StateRoot[0] ^= 1 + err := service.ValidateGossip(t.Context(), child) + require.ErrorIs(t, err, ErrIgnore) + require.ErrorContains(t, err, "already seen") + child.Block.StateRoot[0] ^= 1 + + require.NoError(t, service.ValidateGossip(t.Context(), child)) + require.ErrorIs(t, service.ValidateGossip(t.Context(), child), ErrIgnore) + child.Signature[0] ^= 1 + require.ErrorIs(t, service.ValidateGossip(t.Context(), child), ErrIgnore) +} + +func TestBlockServiceExactRESTReplayClaimIsAtomic(t *testing.T) { + service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated + require.NoError(t, service.ValidateGossip(t.Context(), child)) + service.CommitGossipReservation(child) + service.ReleaseGossipReservation(child) + + errs := make(chan error, 2) + var wg sync.WaitGroup + for range 2 { + wg.Go(func() { errs <- service.ValidateGossip(t.Context(), child) }) + } + wg.Wait() + close(errs) + accepted := 0 + ignored := 0 + for err := range errs { + if err == nil { + accepted++ + } else if errors.Is(err, ErrIgnore) { + ignored++ + } + } + require.Equal(t, 1, accepted) + require.Equal(t, 1, ignored) +} + +func TestBlockServiceFailedExactRESTReplayRestoresClaim(t *testing.T) { + service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) + fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated + require.NoError(t, service.ValidateGossip(t.Context(), child)) + service.CommitGossipReservation(child) + service.ReleaseGossipReservation(child) + require.NoError(t, service.ValidateGossip(t.Context(), child)) + + service.ReleaseGossipReservation(child) + require.NoError(t, service.ValidateGossip(t.Context(), child)) +} + +func TestBlockServiceValidateGossipRejectsMissingBodyBeforeHashing(t *testing.T) { + service := &blockService{} + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) + block.Block.Body = nil + require.ErrorContains(t, service.ValidateGossip(t.Context(), block), "missing beacon block") +} + +func TestScheduledBlockRepairsDatabaseWhenHeaderAlreadyExists(t *testing.T) { + underlying := mdbxtest.NewTestDB(t, dbcfg.ChainDB) + db := &failFirstUpdateDB{RwDB: underlying} + fcu := mock_services.NewForkChoiceStorageMock(t) + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + fcu.Headers[root] = block.SignedBeaconBlockHeader().Header.Copy() + service := &blockService{db: db, forkchoiceStore: fcu} + + service.ScheduleBlockForLaterProcessing(block) + jobValue, ok := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + job := jobValue.(*blockJob) + service.processScheduledBlock(t.Context(), root, job, job.creationTime) + require.True(t, db.failed) + _, ok = service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + service.processScheduledBlock(t.Context(), root, job, job.creationTime) + _, ok = service.blocksScheduledForLaterExecution.Load(root) + require.False(t, ok) + + require.NoError(t, underlying.View(t.Context(), func(tx kv.Tx) error { + body, err := tx.GetOne(kv.BeaconBlocks, dbutils.BlockBodyKey(block.Block.Slot, root)) + require.NoError(t, err) + require.NotEmpty(t, body) + return nil + })) +} + +func TestPublishedBlockJobRetainsFullStoreUntilSuccess(t *testing.T) { + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + db := mdbxtest.NewTestDB(t, dbcfg.ChainDB) + service := &blockService{} + attempts := 0 + storedSidecars := false + imported := false + service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + attempts++ + if attempts == 1 { + return errors.New("sidecar storage unavailable") + } + storedSidecars = true + imported = true + return db.Update(t.Context(), func(tx kv.RwTx) error { + return beacon_indicies.WriteBeaconBlockAndIndicies(t.Context(), tx, block, false) + }) + }) + jobValue, ok := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + job := jobValue.(*blockJob) + service.processScheduledBlock(t.Context(), root, job, job.creationTime) + _, ok = service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + require.False(t, storedSidecars) + service.processScheduledBlock(t.Context(), root, job, job.creationTime) + _, ok = service.blocksScheduledForLaterExecution.Load(root) + require.False(t, ok) + require.True(t, storedSidecars) + require.True(t, imported) + require.NoError(t, db.View(t.Context(), func(tx kv.Tx) error { + body, err := tx.GetOne(kv.BeaconBlocks, dbutils.BlockBodyKey(block.Block.Slot, root)) + require.NoError(t, err) + require.NotEmpty(t, body) + return nil + })) +} + +func TestPublishedBlockJobUpgradesBlockOnlyRecovery(t *testing.T) { + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + service := &blockService{} + service.ScheduleBlockForLaterProcessing(block) + service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil }) + jobValue, ok := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + require.NotNil(t, jobValue.(*blockJob).store) +} + +func TestPublishedBlockUpgradeSurvivesStaleBlockOnlyWorker(t *testing.T) { + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + db := mdbxtest.NewTestDB(t, dbcfg.ChainDB) + fcu := mock_services.NewForkChoiceStorageMock(t) + fcu.Headers[root] = block.SignedBeaconBlockHeader().Header.Copy() + service := &blockService{db: db, forkchoiceStore: fcu} + service.ScheduleBlockForLaterProcessing(block) + staleValue, ok := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + staleJob := staleValue.(*blockJob) + service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil }) + service.processScheduledBlock(t.Context(), root, staleJob, staleJob.creationTime) + currentValue, ok := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + require.NotSame(t, staleJob, currentValue) + require.NotNil(t, currentValue.(*blockJob).store) +} + func TestBlockServicePendingGossipReservationHandsOffToP2P(t *testing.T) { service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated @@ -417,8 +601,8 @@ func TestBlockServiceUnrelatedReservationDoesNotRevalidateP2P(t *testing.T) { }() <-validationEntered otherKey := proposerIndexAndSlot{proposerIndex: child.Block.ProposerIndex + 1, slot: child.Block.Slot} - require.NoError(t, service.(*blockService).reserveGossipKey(otherKey)) - service.(*blockService).releaseGossipKey(otherKey) + require.NoError(t, service.(*blockService).reserveGossipKey(otherKey, common.Hash{1})) + service.(*blockService).releaseGossipKey(otherKey, common.Hash{1}) close(finishValidation) require.NoError(t, <-errCh) require.Equal(t, 1, validationCalls) diff --git a/cl/phase1/network/services/execution_payload_service.go b/cl/phase1/network/services/execution_payload_service.go index 1c2c91907ef..bb7740bf411 100644 --- a/cl/phase1/network/services/execution_payload_service.go +++ b/cl/phase1/network/services/execution_payload_service.go @@ -56,6 +56,7 @@ type pendingEnvelopeKey struct { type envelopeJob struct { envelope *cltypes.SignedExecutionPayloadEnvelope creationTime time.Time + ownedBytes uint64 } const ( @@ -63,6 +64,7 @@ const ( pendingEnvelopeExpiry = 30 * time.Second pendingEnvelopeCheckInterval = 100 * time.Millisecond maxPendingEnvelopes = 1024 + maxPendingEnvelopeBytes = 4 * clparams.MaxChunkSize ) type executionPayloadService struct { @@ -76,6 +78,8 @@ type executionPayloadService struct { // Pending envelopes waiting for block to arrive pendingEnvelopes sync.Map // pendingEnvelopeKey -> *envelopeJob pendingCount atomic.Int32 + pendingBytes atomic.Uint64 + pendingMu sync.Mutex pendingCond *sync.Cond } @@ -142,18 +146,12 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64, // A client MAY queue payload for processing once the block is retrieved. block, ok := s.forkchoiceStore.GetBlock(beaconBlockRoot) if !ok || block == nil { - // Block hasn't arrived yet, queue envelope for later processing - s.queuePendingEnvelope(beaconBlockRoot, signedEnvelope) - // Also store in forkchoice's pendingEnvelopes so OnBlock can process it immediately - // when the block arrives, instead of waiting for the 100ms polling loop. - // validatePayload must be true: if the block arrives (via OnBlock) before this call - // acquires f.mu, the envelope will be applied with validatePayload — ensuring - // NewPayload is sent to the EL. With false, a mutex-contention race silently - // marks the envelope as processed without ever notifying the EL, permanently - // breaking the chain. - if err := s.forkchoiceStore.OnExecutionPayload(ctx, signedEnvelope, false, true); err != nil { - log.Warn("Failed to eagerly store pending execution payload envelope in forkchoice", - "beaconBlockRoot", beaconBlockRoot, "builderIndex", builderIndex, "err", err) + queued, err := s.queuePendingEnvelope(beaconBlockRoot, signedEnvelope) + if err != nil { + return fmt.Errorf("%w: %w", ErrIgnore, err) + } + if !queued { + return fmt.Errorf("%w: execution payload envelope already queued", ErrIgnore) } log.Trace("Queued execution payload envelope for later processing", "beaconBlockRoot", beaconBlockRoot, @@ -277,35 +275,62 @@ func (s *executionPayloadService) emitFullHeadUpdate(block *cltypes.SignedBeacon } // queuePendingEnvelope adds an envelope to the pending queue for later processing -func (s *executionPayloadService) queuePendingEnvelope(blockRoot common.Hash, envelope *cltypes.SignedExecutionPayloadEnvelope) { - if s.pendingCount.Add(1) > maxPendingEnvelopes { - s.pendingCount.Add(-1) - return - } - +func (s *executionPayloadService) queuePendingEnvelope(blockRoot common.Hash, envelope *cltypes.SignedExecutionPayloadEnvelope) (bool, error) { // Compute envelope hash to allow multiple candidates per block envelopeHash, err := envelope.HashSSZ() if err != nil { - s.pendingCount.Add(-1) - log.Warn("Failed to hash envelope for pending queue", "blockRoot", blockRoot, "err", err) - return + return false, fmt.Errorf("failed to hash envelope for pending queue: %w", err) } key := pendingEnvelopeKey{ blockRoot: blockRoot, envelopeHash: envelopeHash, } + ownedBytes := envelope.EncodingSizeSSZ() + if ownedBytes <= 0 { + return false, errors.New("invalid pending execution payload envelope size") + } + ownedBytes64 := uint64(ownedBytes) + + s.pendingMu.Lock() + defer s.pendingMu.Unlock() + if _, loaded := s.pendingEnvelopes.Load(key); loaded { + return false, nil + } + currentBytes := s.pendingBytes.Load() + if s.pendingCount.Load() >= maxPendingEnvelopes || ownedBytes64 > maxPendingEnvelopeBytes || currentBytes > maxPendingEnvelopeBytes-ownedBytes64 { + return false, errors.New("pending execution payload envelope capacity reached") + } - if _, loaded := s.pendingEnvelopes.LoadOrStore(key, &envelopeJob{ + s.pendingEnvelopes.Store(key, &envelopeJob{ envelope: envelope, creationTime: time.Now(), - }); loaded { - s.pendingCount.Add(-1) + ownedBytes: ownedBytes64, + }) + s.pendingCount.Add(1) + s.pendingBytes.Store(currentBytes + ownedBytes64) + s.pendingCond.L.Lock() + s.pendingCond.Signal() + s.pendingCond.L.Unlock() + return true, nil +} + +func (s *executionPayloadService) removePendingEnvelope(key pendingEnvelopeKey) (*envelopeJob, bool) { + s.pendingMu.Lock() + defer s.pendingMu.Unlock() + value, loaded := s.pendingEnvelopes.LoadAndDelete(key) + if !loaded { + return nil, false + } + job := value.(*envelopeJob) + s.pendingCount.Add(-1) + currentBytes := s.pendingBytes.Load() + if job.ownedBytes >= currentBytes { + s.pendingBytes.Store(0) } else { - s.pendingCond.L.Lock() - s.pendingCond.Signal() - s.pendingCond.L.Unlock() + s.pendingBytes.Store(currentBytes - job.ownedBytes) } + return job, true } // loop is the background goroutine that processes pending envelopes @@ -356,9 +381,9 @@ func (s *executionPayloadService) processPendingEnvelopes(ctx context.Context) { // Check expiry if time.Since(job.creationTime) > pendingEnvelopeExpiry { - s.pendingEnvelopes.Delete(pendingKey) - s.pendingCount.Add(-1) - log.Trace("Pending envelope expired", "blockRoot", pendingKey.blockRoot) + if _, removed := s.removePendingEnvelope(pendingKey); removed { + log.Trace("Pending envelope expired", "blockRoot", pendingKey.blockRoot) + } return true } @@ -369,8 +394,10 @@ func (s *executionPayloadService) processPendingEnvelopes(ctx context.Context) { } // Block arrived, remove from pending and process - s.pendingEnvelopes.Delete(pendingKey) - s.pendingCount.Add(-1) + job, removed := s.removePendingEnvelope(pendingKey) + if !removed { + return true + } // Re-run full validation via ProcessMessage if err := s.ProcessMessage(ctx, nil, job.envelope); err != nil { diff --git a/cl/phase1/network/services/execution_payload_service_test.go b/cl/phase1/network/services/execution_payload_service_test.go index 98b8d8dd7ae..4d665169cb4 100644 --- a/cl/phase1/network/services/execution_payload_service_test.go +++ b/cl/phase1/network/services/execution_payload_service_test.go @@ -21,6 +21,7 @@ import ( "encoding/binary" "errors" "sync" + "sync/atomic" "testing" "time" @@ -480,13 +481,16 @@ func TestExecutionPayloadServicePendingEnvelopeExpiry(t *testing.T) { impl.pendingEnvelopes.Store(key, &envelopeJob{ envelope: envelope, creationTime: time.Now().Add(-pendingEnvelopeExpiry - time.Second), // expired + ownedBytes: uint64(envelope.EncodingSizeSSZ()), }) impl.pendingCount.Store(1) + impl.pendingBytes.Store(uint64(envelope.EncodingSizeSSZ())) // Process pending - should remove expired impl.processPendingEnvelopes(ctx) require.Equal(t, int32(0), impl.pendingCount.Load()) + require.Zero(t, impl.pendingBytes.Load()) _, exists := impl.pendingEnvelopes.Load(key) require.False(t, exists) } @@ -520,8 +524,10 @@ func TestExecutionPayloadServicePendingEnvelopeProcessing(t *testing.T) { impl.pendingEnvelopes.Store(key, &envelopeJob{ envelope: envelope, creationTime: time.Now(), + ownedBytes: uint64(envelope.EncodingSizeSSZ()), }) impl.pendingCount.Store(1) + impl.pendingBytes.Store(uint64(envelope.EncodingSizeSSZ())) // Block not yet available - should keep pending impl.processPendingEnvelopes(ctx) @@ -537,6 +543,7 @@ func TestExecutionPayloadServicePendingEnvelopeProcessing(t *testing.T) { // Process again - should process and remove impl.processPendingEnvelopes(ctx) require.Equal(t, int32(0), impl.pendingCount.Load()) + require.Zero(t, impl.pendingBytes.Load()) _, exists := impl.pendingEnvelopes.Load(key) require.False(t, exists) @@ -657,6 +664,178 @@ func TestExecutionPayloadServicePendingQueueCapConcurrent(t *testing.T) { require.Equal(t, 5, stored) } +func TestExecutionPayloadServicePendingQueueOwnsBoundedBytes(t *testing.T) { + service, forkchoiceMock := setupExecutionPayloadService(t) + var forkchoiceAdmissions atomic.Int32 + forkchoiceMock.OnExecutionPayloadFn = func(context.Context, *cltypes.SignedExecutionPayloadEnvelope, bool, bool) error { + forkchoiceAdmissions.Add(1) + return nil + } + + largeTransaction := make([]byte, int(clparams.MaxChunkSize)-1024) + for i := range 5 { + root := common.Hash{byte(i + 1)} + envelope := newTestSignedEnvelope(100, root, uint64(i+1)) + envelope.Message.Payload.Transactions = solid.NewTransactionsSSZFromTransactions([][]byte{largeTransaction}) + err := service.ProcessMessage(t.Context(), nil, envelope) + require.ErrorIs(t, err, ErrIgnore) + if i == 4 { + require.ErrorContains(t, err, "capacity reached") + } + } + + impl := service.(*executionPayloadService) + require.Equal(t, int32(4), impl.pendingCount.Load()) + require.Zero(t, forkchoiceAdmissions.Load()) +} + +func TestExecutionPayloadServiceProcessesEnvelopeWhenBlockArrivesAfterAdmission(t *testing.T) { + service, forkchoiceMock := setupExecutionPayloadService(t) + blockRoot := common.Hash{1} + envelope := newTestSignedEnvelope(100, blockRoot, 1) + type call struct { + checkBlobData bool + validatePayload bool + } + calls := make(chan call, 2) + forkchoiceMock.OnExecutionPayloadFn = func(_ context.Context, got *cltypes.SignedExecutionPayloadEnvelope, checkBlobData, validatePayload bool) error { + require.Same(t, envelope, got) + calls <- call{checkBlobData: checkBlobData, validatePayload: validatePayload} + return nil + } + + require.ErrorIs(t, service.ProcessMessage(t.Context(), nil, envelope), ErrIgnore) + require.Empty(t, calls) + forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100}} + service.(*executionPayloadService).processPendingEnvelopes(t.Context()) + + require.Equal(t, call{checkBlobData: true, validatePayload: true}, <-calls) + require.Empty(t, calls) + require.Zero(t, service.(*executionPayloadService).pendingCount.Load()) + require.Zero(t, service.(*executionPayloadService).pendingBytes.Load()) +} + +func TestExecutionPayloadServiceProcessesEnvelopeWhenBlockArrivesBeforeAdmission(t *testing.T) { + service, forkchoiceMock := setupExecutionPayloadService(t) + blockRoot := common.Hash{1} + envelope := newTestSignedEnvelope(100, blockRoot, 1) + forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100}} + var calls atomic.Int32 + forkchoiceMock.OnExecutionPayloadFn = func(_ context.Context, got *cltypes.SignedExecutionPayloadEnvelope, checkBlobData, validatePayload bool) error { + require.Same(t, envelope, got) + require.True(t, checkBlobData) + require.True(t, validatePayload) + calls.Add(1) + return nil + } + + require.NoError(t, service.ProcessMessage(t.Context(), nil, envelope)) + require.Equal(t, int32(1), calls.Load()) + require.Zero(t, service.(*executionPayloadService).pendingCount.Load()) + require.Zero(t, service.(*executionPayloadService).pendingBytes.Load()) +} + +func TestExecutionPayloadServicePendingByteAdmissionConcurrent(t *testing.T) { + cfg := &clparams.MainnetBeaconConfig + forkchoiceMock := mock_services.NewForkChoiceStorageMock(t) + impl := &executionPayloadService{ + forkchoiceStore: forkchoiceMock, + beaconCfg: cfg, + emitters: beaconevents.NewEventEmitter(), + pendingCond: sync.NewCond(&sync.Mutex{}), + } + envelopeSize := uint64(newTestSignedEnvelope(100, common.Hash{1}, 1).EncodingSizeSSZ()) + impl.pendingBytes.Store(maxPendingEnvelopeBytes - 5*envelopeSize) + + type result struct { + queued bool + err error + } + results := make(chan result, 100) + var wg sync.WaitGroup + for i := range 100 { + wg.Go(func() { + queued, err := impl.queuePendingEnvelope( + common.Hash{byte(i), byte(i >> 8)}, + newTestSignedEnvelope(100, common.Hash{byte(i), byte(i >> 8)}, uint64(i+1)), + ) + results <- result{queued: queued, err: err} + }) + } + wg.Wait() + close(results) + + admitted := 0 + for result := range results { + if result.queued { + require.NoError(t, result.err) + admitted++ + continue + } + require.ErrorContains(t, result.err, "capacity reached") + } + require.Equal(t, 5, admitted) + require.Equal(t, int32(5), impl.pendingCount.Load()) + require.Equal(t, maxPendingEnvelopeBytes, impl.pendingBytes.Load()) +} + +func TestExecutionPayloadServiceDuplicateAtByteCapacityDoesNotReadmitForkchoice(t *testing.T) { + service, forkchoiceMock := setupExecutionPayloadService(t) + var forkchoiceAdmissions atomic.Int32 + forkchoiceMock.OnExecutionPayloadFn = func(context.Context, *cltypes.SignedExecutionPayloadEnvelope, bool, bool) error { + forkchoiceAdmissions.Add(1) + return nil + } + envelope := newTestSignedEnvelope(100, common.Hash{1}, 1) + require.ErrorIs(t, service.ProcessMessage(t.Context(), nil, envelope), ErrIgnore) + + impl := service.(*executionPayloadService) + impl.pendingBytes.Store(maxPendingEnvelopeBytes) + require.ErrorIs(t, service.ProcessMessage(t.Context(), nil, envelope), ErrIgnore) + require.Equal(t, int32(1), impl.pendingCount.Load()) + require.Zero(t, forkchoiceAdmissions.Load()) +} + +func TestExecutionPayloadServiceConcurrentDuplicateRemovalConservesOwnership(t *testing.T) { + impl := &executionPayloadService{pendingCond: sync.NewCond(&sync.Mutex{})} + blockRoot := common.Hash{1} + envelope := newTestSignedEnvelope(100, blockRoot, 1) + envelopeHash, err := envelope.HashSSZ() + require.NoError(t, err) + key := pendingEnvelopeKey{blockRoot: blockRoot, envelopeHash: envelopeHash} + queued, err := impl.queuePendingEnvelope(blockRoot, envelope) + require.NoError(t, err) + require.True(t, queued) + + results := make(chan error, 50) + var wg sync.WaitGroup + for i := range 100 { + if i%2 == 0 { + wg.Go(func() { _, _ = impl.removePendingEnvelope(key) }) + continue + } + wg.Go(func() { + _, err := impl.queuePendingEnvelope(blockRoot, envelope) + results <- err + }) + } + wg.Wait() + close(results) + for err := range results { + require.NoError(t, err) + } + + stored := 0 + ownedBytes := uint64(0) + impl.pendingEnvelopes.Range(func(_, value any) bool { + stored++ + ownedBytes += value.(*envelopeJob).ownedBytes + return true + }) + require.Equal(t, int32(stored), impl.pendingCount.Load()) + require.Equal(t, ownedBytes, impl.pendingBytes.Load()) +} + func TestExecutionPayloadServiceNames(t *testing.T) { service, _ := setupExecutionPayloadService(t) impl := service.(*executionPayloadService) diff --git a/cl/phase1/network/services/mock_services/block_service_mock.go b/cl/phase1/network/services/mock_services/block_service_mock.go index e903c51148a..2f3d16572b1 100644 --- a/cl/phase1/network/services/mock_services/block_service_mock.go +++ b/cl/phase1/network/services/mock_services/block_service_mock.go @@ -266,6 +266,42 @@ func (c *MockBlockServiceScheduleBlockForLaterProcessingCall) DoAndReturn(f func return c } +// SchedulePublishedBlockForLaterProcessing mocks base method. +func (m *MockBlockService) SchedulePublishedBlockForLaterProcessing(arg0 *cltypes.SignedBeaconBlock, arg1 func(context.Context) error) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SchedulePublishedBlockForLaterProcessing", arg0, arg1) +} + +// SchedulePublishedBlockForLaterProcessing indicates an expected call of SchedulePublishedBlockForLaterProcessing. +func (mr *MockBlockServiceMockRecorder) SchedulePublishedBlockForLaterProcessing(arg0, arg1 any) *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SchedulePublishedBlockForLaterProcessing", reflect.TypeOf((*MockBlockService)(nil).SchedulePublishedBlockForLaterProcessing), arg0, arg1) + return &MockBlockServiceSchedulePublishedBlockForLaterProcessingCall{Call: call} +} + +// MockBlockServiceSchedulePublishedBlockForLaterProcessingCall wrap *gomock.Call +type MockBlockServiceSchedulePublishedBlockForLaterProcessingCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall) Return() *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall) Do(f func(*cltypes.SignedBeaconBlock, func(context.Context) error)) *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall) DoAndReturn(f func(*cltypes.SignedBeaconBlock, func(context.Context) error)) *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // ValidateGossip mocks base method. func (m *MockBlockService) ValidateGossip(arg0 context.Context, arg1 *cltypes.SignedBeaconBlock) error { m.ctrl.T.Helper() diff --git a/cl/phase1/network/services/types.go b/cl/phase1/network/services/types.go index ee6a691c77a..43b498cce59 100644 --- a/cl/phase1/network/services/types.go +++ b/cl/phase1/network/services/types.go @@ -14,6 +14,7 @@ type BlockService interface { CommitGossipReservation(*cltypes.SignedBeaconBlock) ReleaseGossipReservation(*cltypes.SignedBeaconBlock) ScheduleBlockForLaterProcessing(*cltypes.SignedBeaconBlock) + SchedulePublishedBlockForLaterProcessing(*cltypes.SignedBeaconBlock, func(context.Context) error) } //go:generate mockgen -typed=true -destination=./mock_services/blob_sidecars_service_mock.go -package=mock_services . BlobSidecarsService From e1a5ef0e3d740f2c9db112682d25500db630eb37 Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 20:41:27 +0800 Subject: [PATCH 32/43] cl/beacon: update block service test fixture --- cl/beacon/handler/utils_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cl/beacon/handler/utils_test.go b/cl/beacon/handler/utils_test.go index b7525a3473b..c1d4fa77cac 100644 --- a/cl/beacon/handler/utils_test.go +++ b/cl/beacon/handler/utils_test.go @@ -132,6 +132,7 @@ func setupTestingHandler(t *testing.T, v clparams.StateVersion, logger log.Logge blockService.EXPECT().CommitGossipReservation(gomock.Any()).AnyTimes() blockService.EXPECT().ReleaseGossipReservation(gomock.Any()).AnyTimes() blockService.EXPECT().ScheduleBlockForLaterProcessing(gomock.Any()).AnyTimes() + blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(gomock.Any(), gomock.Any()).AnyTimes() // ctx context.Context, subnetID *uint64, msg *cltypes.SyncCommitteeMessage) error syncCommitteeMessagesService.EXPECT().ProcessMessage(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, subnetID *uint64, msg *services.SyncCommitteeMessageForGossip) error { From 5ec6ac29a46f0dde6d0ccf4470c65e2f2b21c3d0 Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 20:44:57 +0800 Subject: [PATCH 33/43] cl: check pending envelope admission results --- .../network/services/execution_payload_service_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cl/phase1/network/services/execution_payload_service_test.go b/cl/phase1/network/services/execution_payload_service_test.go index 4d665169cb4..1949dda86d8 100644 --- a/cl/phase1/network/services/execution_payload_service_test.go +++ b/cl/phase1/network/services/execution_payload_service_test.go @@ -620,7 +620,9 @@ func TestExecutionPayloadServicePendingQueueCap(t *testing.T) { blockRoot := common.HexToHash("0xffff") envelope := newTestSignedEnvelope(100, blockRoot, 999) - impl.queuePendingEnvelope(blockRoot, envelope) + queued, err := impl.queuePendingEnvelope(blockRoot, envelope) + require.Error(t, err) + require.False(t, queued) require.Equal(t, int32(maxPendingEnvelopes), impl.pendingCount.Load()) envelopeHash, err := envelope.HashSSZ() @@ -650,7 +652,7 @@ func TestExecutionPayloadServicePendingQueueCapConcurrent(t *testing.T) { wg.Go(func() { blockRoot := common.Hash{byte(i), byte(i >> 8)} envelope := newTestSignedEnvelope(100, blockRoot, uint64(10000+i)) - impl.queuePendingEnvelope(blockRoot, envelope) + _, _ = impl.queuePendingEnvelope(blockRoot, envelope) }) } wg.Wait() From 2378fa1680513c2b6d10423bf4da0fdd81d6ad4b Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 21:19:42 +0800 Subject: [PATCH 34/43] cl: preserve Gloas sync and retry progress --- cl/phase1/forkchoice/forkchoice_test.go | 106 ++++++++++++++++++ cl/phase1/forkchoice/on_block.go | 5 +- cl/phase1/network/beacon_downloader.go | 17 ++- cl/phase1/network/beacon_downloader_test.go | 70 ++++++++++++ cl/phase1/network/services/block_service.go | 10 +- .../network/services/block_service_test.go | 84 +++++++++++++- 6 files changed, 278 insertions(+), 14 deletions(-) diff --git a/cl/phase1/forkchoice/forkchoice_test.go b/cl/phase1/forkchoice/forkchoice_test.go index 694a58d0fe9..7a11acc57d1 100644 --- a/cl/phase1/forkchoice/forkchoice_test.go +++ b/cl/phase1/forkchoice/forkchoice_test.go @@ -17,6 +17,7 @@ package forkchoice import ( + "context" "errors" "slices" "sync" @@ -27,16 +28,121 @@ import ( "github.com/stretchr/testify/require" "github.com/erigontech/erigon/cl/beacon/beaconevents" + "github.com/erigontech/erigon/cl/beacon/synced_data" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" "github.com/erigontech/erigon/cl/phase1/core/state" + "github.com/erigontech/erigon/cl/phase1/execution_client" "github.com/erigontech/erigon/cl/phase1/forkchoice/fork_graph" + "github.com/erigontech/erigon/cl/phase1/forkchoice/public_keys_registry" "github.com/erigontech/erigon/cl/pool" "github.com/erigontech/erigon/cl/transition/impl/eth2" + "github.com/erigontech/erigon/cl/utils/eth_clock" + "github.com/erigontech/erigon/cl/validator/validator_params" "github.com/erigontech/erigon/common" ) +type embeddedPtcVoteForkGraph struct { + *getFinalizedExecutionHashForkGraph + postState *state.CachingBeaconState + envelopes map[common.Hash]bool +} + +func (g *embeddedPtcVoteForkGraph) AddChainSegment(block *cltypes.SignedBeaconBlock, _ bool) (*state.CachingBeaconState, fork_graph.ChainSegmentInsertionResult, error) { + root, err := block.Block.HashSSZ() + if err != nil { + return nil, fork_graph.InvalidBlock, err + } + g.blocks[root] = block + g.headers[root] = &cltypes.BeaconBlockHeader{Slot: block.Block.Slot, ParentRoot: block.Block.ParentRoot} + g.addChainSegmentCalled = true + return g.postState, fork_graph.Success, nil +} + +func (g *embeddedPtcVoteForkGraph) HasEnvelope(root common.Hash) bool { + return g.envelopes[root] +} + +func TestOnBlockFromForwardSyncAppliesEmbeddedPtcVotes(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + cfg.AltairForkEpoch = 0 + cfg.BellatrixForkEpoch = 0 + cfg.CapellaForkEpoch = 0 + cfg.DenebForkEpoch = 0 + cfg.ElectraForkEpoch = 0 + cfg.FuluForkEpoch = 0 + cfg.GloasForkEpoch = 0 + cfg.InitializeForkSchedule() + + anchor := state.New(&cfg) + anchor.SetVersion(clparams.GloasVersion) + require.NoError(t, anchor.SetSlot(1)) + anchor.SetPtcWindow(solid.NewUint64VectorOfVectors(int((2+cfg.MinSeedLookahead)*cfg.SlotsPerEpoch), int(cfg.PtcSize))) + anchorRoot, err := anchor.BlockRoot() + require.NoError(t, err) + + postState, err := anchor.Copy() + require.NoError(t, err) + require.NoError(t, postState.SetSlot(2)) + graph := &embeddedPtcVoteForkGraph{ + getFinalizedExecutionHashForkGraph: &getFinalizedExecutionHashForkGraph{ + blocks: make(map[common.Hash]*cltypes.SignedBeaconBlock), + headers: map[common.Hash]*cltypes.BeaconBlockHeader{anchorRoot: {Slot: 1}}, + states: map[common.Hash]*state.CachingBeaconState{anchorRoot: anchor}, + anchorRoot: anchorRoot, + anchorSlot: 1, + }, + postState: postState, + envelopes: map[common.Hash]bool{anchorRoot: true}, + } + clock := eth_clock.NewEthereumClock(0, common.Hash{}, &cfg) + store, err := NewForkChoiceStore( + clock, + anchor, + nil, + pool.NewOperationsPool(&cfg), + graph, + beaconevents.NewEventEmitter(), + synced_data.NewSyncedDataManager(&cfg, true), + nil, + public_keys_registry.NewInMemoryPublicKeysRegistry(), + validator_params.NewValidatorParams(), + false, + nil, + ) + require.NoError(t, err) + store.OnTick(2 * cfg.SecondsPerSlot) + store.payloadStatusByRoot.Add(anchorRoot, execution_client.PayloadStatusValidated) + + child := cltypes.NewSignedBeaconBlock(&cfg, clparams.GloasVersion) + child.Block.Slot = 2 + child.Block.ParentRoot = anchorRoot + votes := solid.NewBitVector(int(cfg.PtcSize)) + for i := range int(cfg.PtcSize/2 + 1) { + require.NoError(t, votes.SetBitAt(i, true)) + } + child.Block.Body.PayloadAttestations.Append(&cltypes.PayloadAttestation{ + AggregationBits: votes, + Data: &cltypes.PayloadAttestationData{ + BeaconBlockRoot: anchorRoot, + Slot: 1, + PayloadPresent: false, + BlobDataAvailable: false, + }, + }) + + require.NoError(t, store.OnBlock(context.Background(), child, false, false, false)) + require.True(t, store.payloadTimeliness(anchorRoot, false)) + require.True(t, store.payloadDataAvailability(anchorRoot, false)) + require.False(t, store.ShouldBuildOnFull(ForkChoiceNode{Root: anchorRoot, PayloadStatus: cltypes.PayloadStatusFull}, 2)) + head, err := store.GetHeadNode() + require.NoError(t, err) + childRoot, err := child.Block.HashSSZ() + require.NoError(t, err) + require.Equal(t, ForkChoiceNode{Root: childRoot, PayloadStatus: cltypes.PayloadStatusEmpty}, head) +} + type headerOnlyAnchorForkGraph struct { fork_graph.ForkGraph root common.Hash diff --git a/cl/phase1/forkchoice/on_block.go b/cl/phase1/forkchoice/on_block.go index 505ed48412f..207d169488a 100644 --- a/cl/phase1/forkchoice/on_block.go +++ b/cl/phase1/forkchoice/on_block.go @@ -356,10 +356,7 @@ func (f *ForkChoiceStore) onBlock(ctx context.Context, block *cltypes.SignedBeac f.payloadTimelinessVote.Store(common.Hash(blockRoot), [clparams.PtcSize]int8{}) f.payloadDataAvailabilityVote.Store(common.Hash(blockRoot), [clparams.PtcSize]int8{}) - // Notify PTC messages from payload attestations in the block. - // Skip during forward sync (newPayload=false) — PTC votes only matter - // for fork choice at the chain tip, not for historical blocks. - if block.Block.Body.PayloadAttestations != nil && newPayload { + if block.Block.Body.PayloadAttestations != nil { f.notifyPtcMessages(lastProcessedState, block.Block.Body.PayloadAttestations) } diff --git a/cl/phase1/network/beacon_downloader.go b/cl/phase1/network/beacon_downloader.go index 443e3a703cb..dddc4868259 100644 --- a/cl/phase1/network/beacon_downloader.go +++ b/cl/phase1/network/beacon_downloader.go @@ -56,6 +56,7 @@ type ForwardBeaconDownloader struct { highestSlotUpdateTime time.Time minSlot uint64 // earliest requestable slot (e.g. checkpoint anchor) rpc *rpc.BeaconRpcP2P + requestBlocksByRange func(context.Context, uint64, uint64) ([]*cltypes.SignedBeaconBlock, string, error) process ProcessFn beaconCfg *clparams.BeaconChainConfig httpFallbackURL string // beacon API base URL for HTTP fallback when P2P fails @@ -65,11 +66,15 @@ type ForwardBeaconDownloader struct { } func NewForwardBeaconDownloader(ctx context.Context, rpc *rpc.BeaconRpcP2P, beaconCfg *clparams.BeaconChainConfig) *ForwardBeaconDownloader { - return &ForwardBeaconDownloader{ + f := &ForwardBeaconDownloader{ ctx: ctx, rpc: rpc, beaconCfg: beaconCfg, } + if rpc != nil { + f.requestBlocksByRange = rpc.SendBeaconBlocksByRangeReq + } + return f } // SetProcessFunction sets the function used to process segments. @@ -180,7 +185,7 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { if time.Since(f.highestSlotUpdateTime) > 90*time.Second { log.Trace("Forward beacon downloader gets stuck", "time", time.Since(f.highestSlotUpdateTime).Seconds(), "highestSlotProcessed", f.highestSlotProcessed) } - responses, peerId, err := f.rpc.SendBeaconBlocksByRangeReq(ctx, reqSlot, reqCount) + responses, peerId, err := f.requestBlocksByRange(ctx, reqSlot, reqCount) if err != nil { if errors.Is(err, peers.ErrNoPeers) { log.Debug("[Caplin] no peers available for beacon blocks by range request", "slot", reqSlot, "reqCount", reqCount) @@ -226,8 +231,14 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { if len(responses) == 0 { // Empty response: no blocks in this slot range. // Advance past the requested range so we don't get stuck requesting the same empty range. + if reqCount == 0 { + return + } + newSlot := reqSlot + reqCount - 1 + if reqCount-1 > math.MaxUint64-reqSlot { + newSlot = math.MaxUint64 + } f.mu.Lock() - newSlot := reqSlot + count if newSlot > f.highestSlotProcessed { log.Debug("Empty block range response, advancing past gap", "from", f.highestSlotProcessed, "to", newSlot, "peer", peerId) f.highestSlotProcessed = newSlot diff --git a/cl/phase1/network/beacon_downloader_test.go b/cl/phase1/network/beacon_downloader_test.go index ba2d782d066..30f0851ddd4 100644 --- a/cl/phase1/network/beacon_downloader_test.go +++ b/cl/phase1/network/beacon_downloader_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/stretchr/testify/require" @@ -16,6 +17,75 @@ import ( "github.com/erigontech/erigon/common" ) +func TestForwardBeaconDownloaderEmptyCappedRangeStopsAtForkBoundary(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + cfg.SlotsPerEpoch = 10 + cfg.AltairForkEpoch = ^uint64(0) + cfg.BellatrixForkEpoch = ^uint64(0) + cfg.CapellaForkEpoch = ^uint64(0) + cfg.DenebForkEpoch = ^uint64(0) + cfg.ElectraForkEpoch = ^uint64(0) + cfg.FuluForkEpoch = ^uint64(0) + cfg.GloasForkEpoch = 11 + + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2) + downloader := &ForwardBeaconDownloader{ + beaconCfg: &cfg, + requestBlocksByRange: func(_ context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + return []*cltypes.SignedBeaconBlock{}, "block-peer", nil + }, + } + downloader.SetHighestProcessedSlot(100) + + runRequest := func() cltypes.BeaconBlocksByRangeRequest { + frontier := downloader.GetHighestProcessedSlot() + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(ctx) + }() + request := <-requests + require.Eventually(t, func() bool { + return downloader.GetHighestProcessedSlot() != frontier + }, time.Second, 10*time.Millisecond) + cancel() + <-done + return request + } + + first := runRequest() + require.Equal(t, uint64(98), first.StartSlot) + require.Equal(t, uint64(12), first.Count) + require.Equal(t, uint64(109), downloader.GetHighestProcessedSlot()) + + second := runRequest() + require.Equal(t, uint64(110), second.StartSlot) + require.Equal(t, uint64(30), second.Count) +} + +func TestCapAtForkBoundaryKeepsHalfOpenBoundaryRanges(t *testing.T) { + cfg := clparams.MainnetBeaconConfig + cfg.SlotsPerEpoch = 10 + cfg.AltairForkEpoch = ^uint64(0) + cfg.BellatrixForkEpoch = ^uint64(0) + cfg.CapellaForkEpoch = ^uint64(0) + cfg.DenebForkEpoch = ^uint64(0) + cfg.ElectraForkEpoch = ^uint64(0) + cfg.FuluForkEpoch = ^uint64(0) + cfg.GloasForkEpoch = 11 + downloader := &ForwardBeaconDownloader{beaconCfg: &cfg, highestSlotProcessed: 100} + + start, count := downloader.capAtForkBoundary(98, 12) + require.Equal(t, uint64(98), start) + require.Equal(t, uint64(12), count) + + start, count = downloader.capAtForkBoundary(110, 33) + require.Equal(t, uint64(110), start) + require.Equal(t, uint64(33), count) +} + func TestShouldBanProcessPeer(t *testing.T) { processErr := errors.New("process failed") diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index 86656ad271c..d6cd9634935 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -197,10 +197,6 @@ func (b *blockService) ReleaseGossipReservation(msg *cltypes.SignedBeaconBlock) func (b *blockService) validateFirstGossip(ctx context.Context, msg *cltypes.SignedBeaconBlock, schedule func(), waitForPending bool) error { key := blockGossipKey(msg) - root, err := msg.HashSSZ() - if err != nil { - return err - } for { if err := ctx.Err(); err != nil { return fmt.Errorf("%w: block validation canceled: %w", ErrIgnore, err) @@ -239,6 +235,10 @@ func (b *blockService) validateFirstGossip(ctx context.Context, msg *cltypes.Sig b.seenBlocksMu.Unlock() validationErr := b.validateGossip(ctx, msg, schedule) + var root [32]byte + if validationErr == nil && ctx.Err() == nil { + root, validationErr = msg.HashSSZ() + } b.seenBlocksMu.Lock() reservation.validators-- @@ -598,7 +598,7 @@ func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeac return } existing := existingValue.(*blockJob) - if existing.store != nil || store == nil { + if store == nil || (existing.store != nil && !job.creationTime.After(existing.creationTime)) { return } if b.blocksScheduledForLaterExecution.CompareAndSwap(blockRoot, existing, job) { diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index 1d5a05bb22a..734e34a0b29 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -195,10 +195,15 @@ func TestBlockServiceSuccess(t *testing.T) { blocks[1].Block.Body.BlobKzgCommitments = solid.NewStaticListSSZ[*cltypes.KZGCommitment](100, 48) require.NoError(t, service.ProcessMessage(context.Background(), nil, blocks[1])) - require.True(t, service.(*blockService).seenBlocksCache.Contains(proposerIndexAndSlot{ + key := proposerIndexAndSlot{ proposerIndex: blocks[1].Block.ProposerIndex, slot: blocks[1].Block.Slot, - })) + } + seen, ok := service.(*blockService).seenBlocksCache.Get(key) + require.True(t, ok) + signedRoot, err := blocks[1].HashSSZ() + require.NoError(t, err) + require.Equal(t, common.Hash(signedRoot), seen.signedRoot) } func TestBlockServiceGossipRejectsBlockOutsideFinalizedChain(t *testing.T) { @@ -416,6 +421,18 @@ func TestBlockServiceValidateGossipRejectsMissingBodyBeforeHashing(t *testing.T) require.ErrorContains(t, service.ValidateGossip(t.Context(), block), "missing beacon block") } +func TestBlockServiceP2PDuplicateIsIgnoredBeforeHashing(t *testing.T) { + service, child, _, _, _ := newGloasGossipValidationFixture(t, nil) + child.Block.Body.Eth1Data = nil + key := blockGossipKey(child) + service.(*blockService).seenBlocksCache.Add(key, seenBlock{}) + + err := service.ProcessMessage(t.Context(), nil, child) + + require.ErrorIs(t, err, ErrIgnore) + require.Nil(t, child.Block.Body.Eth1Data) +} + func TestScheduledBlockRepairsDatabaseWhenHeaderAlreadyExists(t *testing.T) { underlying := mdbxtest.NewTestDB(t, dbcfg.ChainDB) db := &failFirstUpdateDB{RwDB: underlying} @@ -498,6 +515,41 @@ func TestPublishedBlockJobUpgradesBlockOnlyRecovery(t *testing.T) { require.NotNil(t, jobValue.(*blockJob).store) } +func TestPublishedBlockJobIsNotDowngradedByBlockOnlyRecovery(t *testing.T) { + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + service := &blockService{} + service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil }) + fullValue, ok := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + service.ScheduleBlockForLaterProcessing(block) + currentValue, ok := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + require.Same(t, fullValue, currentValue) +} + +func TestOlderPublishedBlockJobDoesNotReplaceNewerFullStore(t *testing.T) { + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + newer := &blockJob{ + block: block, + store: func(context.Context) error { return nil }, + creationTime: time.Now().Add(time.Minute), + } + service := &blockService{} + service.blocksScheduledForLaterExecution.Store(root, newer) + + service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + return errors.New("older store should not replace newer store") + }) + + currentValue, ok := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + require.Same(t, newer, currentValue) +} + func TestPublishedBlockUpgradeSurvivesStaleBlockOnlyWorker(t *testing.T) { block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) root, err := block.Block.HashSSZ() @@ -518,6 +570,34 @@ func TestPublishedBlockUpgradeSurvivesStaleBlockOnlyWorker(t *testing.T) { require.NotNil(t, currentValue.(*blockJob).store) } +func TestPublishedBlockRefreshSurvivesStaleFullStoreWorker(t *testing.T) { + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + service := &blockService{} + service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + return errors.New("stale store should not run") + }) + staleValue, ok := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + staleJob := staleValue.(*blockJob) + freshStoreCalls := 0 + service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + freshStoreCalls++ + return nil + }) + staleJob.creationTime = time.Now().Add(-blockJobExpiry - time.Second) + service.processScheduledBlock(t.Context(), root, staleJob, time.Now()) + + freshValue, ok := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + require.NotSame(t, staleJob, freshValue) + service.processScheduledBlock(t.Context(), root, freshValue.(*blockJob), time.Now()) + require.Equal(t, 1, freshStoreCalls) + _, ok = service.blocksScheduledForLaterExecution.Load(root) + require.False(t, ok) +} + func TestBlockServicePendingGossipReservationHandsOffToP2P(t *testing.T) { service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil) fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated From ff8094c58e64db4404c4dc234d0edaf05e940ffd Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 22:32:24 +0800 Subject: [PATCH 35/43] cl: preserve sparse Gloas sync progress --- cl/phase1/forkchoice/forkchoice_test.go | 89 ++++- cl/phase1/forkchoice/payload_vote.go | 44 ++- cl/phase1/network/beacon_downloader.go | 247 +++++++++++--- cl/phase1/network/beacon_downloader_test.go | 346 ++++++++++++++++++++ 4 files changed, 660 insertions(+), 66 deletions(-) diff --git a/cl/phase1/forkchoice/forkchoice_test.go b/cl/phase1/forkchoice/forkchoice_test.go index 7a11acc57d1..eb39df5a9ff 100644 --- a/cl/phase1/forkchoice/forkchoice_test.go +++ b/cl/phase1/forkchoice/forkchoice_test.go @@ -64,8 +64,69 @@ func (g *embeddedPtcVoteForkGraph) HasEnvelope(root common.Hash) bool { return g.envelopes[root] } -func TestOnBlockFromForwardSyncAppliesEmbeddedPtcVotes(t *testing.T) { +func TestOnBlockFromForwardSyncExpandsEmbeddedPtcVotesForDuplicateValidator(t *testing.T) { + committee := make([]uint64, clparams.MaxPtcSize) + for i := range committee { + committee[i] = uint64(i + 1) + } + committee[clparams.MaxPtcSize/2] = committee[0] + selectedPositions := make([]int, clparams.MaxPtcSize/2) + for i := range selectedPositions { + selectedPositions[i] = i + } + + store, anchorRoot, child := runEmbeddedPtcVoteBlock(t, clparams.MaxPtcSize, committee, selectedPositions) + require.True(t, store.payloadTimeliness(anchorRoot, false)) + require.True(t, store.payloadDataAvailability(anchorRoot, false)) + require.False(t, store.ShouldBuildOnFull(ForkChoiceNode{Root: anchorRoot, PayloadStatus: cltypes.PayloadStatusFull}, 2)) + head, err := store.GetHeadNode() + require.NoError(t, err) + childRoot, err := child.Block.HashSSZ() + require.NoError(t, err) + require.Equal(t, ForkChoiceNode{Root: childRoot, PayloadStatus: cltypes.PayloadStatusEmpty}, head) +} + +func TestOnBlockFromForwardSyncAppliesEmbeddedPtcVotesForUniqueValidators(t *testing.T) { + committee := make([]uint64, clparams.MaxPtcSize) + for i := range committee { + committee[i] = uint64(i + 1) + } + selectedPositions := make([]int, clparams.MaxPtcSize/2+1) + for i := range selectedPositions { + selectedPositions[i] = i + } + + store, anchorRoot, child := runEmbeddedPtcVoteBlock(t, clparams.MaxPtcSize, committee, selectedPositions) + require.True(t, store.payloadTimeliness(anchorRoot, false)) + require.True(t, store.payloadDataAvailability(anchorRoot, false)) + head, err := store.GetHeadNode() + require.NoError(t, err) + childRoot, err := child.Block.HashSSZ() + require.NoError(t, err) + require.Equal(t, ForkChoiceNode{Root: childRoot, PayloadStatus: cltypes.PayloadStatusEmpty}, head) +} + +func TestOnBlockFromForwardSyncUsesMaxPtcSizeForZeroConfig(t *testing.T) { + committee := make([]uint64, clparams.MaxPtcSize) + for i := range committee { + committee[i] = uint64(i + 1) + } + + store, anchorRoot, _ := runEmbeddedPtcVoteBlock(t, 0, committee, []int{int(clparams.MaxPtcSize - 1)}) + votes := store.payloadTimelinessVoteValue(anchorRoot) + require.Equal(t, int8(-1), votes[clparams.MaxPtcSize-1]) + require.Equal(t, int8(1), votes[clparams.MaxPtcSize-2]) +} + +func runEmbeddedPtcVoteBlock( + t *testing.T, + configuredPtcSize uint64, + committee []uint64, + selectedPositions []int, +) (*ForkChoiceStore, common.Hash, *cltypes.SignedBeaconBlock) { + t.Helper() cfg := clparams.MainnetBeaconConfig + cfg.PtcSize = configuredPtcSize cfg.AltairForkEpoch = 0 cfg.BellatrixForkEpoch = 0 cfg.CapellaForkEpoch = 0 @@ -78,7 +139,16 @@ func TestOnBlockFromForwardSyncAppliesEmbeddedPtcVotes(t *testing.T) { anchor := state.New(&cfg) anchor.SetVersion(clparams.GloasVersion) require.NoError(t, anchor.SetSlot(1)) - anchor.SetPtcWindow(solid.NewUint64VectorOfVectors(int((2+cfg.MinSeedLookahead)*cfg.SlotsPerEpoch), int(cfg.PtcSize))) + ptcSize := configuredPtcSize + if ptcSize == 0 { + ptcSize = clparams.MaxPtcSize + } + ptcWindow := solid.NewUint64VectorOfVectors(int((2+cfg.MinSeedLookahead)*cfg.SlotsPerEpoch), int(ptcSize)) + ptc := ptcWindow.Get(int(cfg.SlotsPerEpoch + anchor.Slot()%cfg.SlotsPerEpoch)) + for i, validatorIndex := range committee { + ptc.Set(i, validatorIndex) + } + anchor.SetPtcWindow(ptcWindow) anchorRoot, err := anchor.BlockRoot() require.NoError(t, err) @@ -118,9 +188,9 @@ func TestOnBlockFromForwardSyncAppliesEmbeddedPtcVotes(t *testing.T) { child := cltypes.NewSignedBeaconBlock(&cfg, clparams.GloasVersion) child.Block.Slot = 2 child.Block.ParentRoot = anchorRoot - votes := solid.NewBitVector(int(cfg.PtcSize)) - for i := range int(cfg.PtcSize/2 + 1) { - require.NoError(t, votes.SetBitAt(i, true)) + votes := solid.NewBitVector(int(ptcSize)) + for _, position := range selectedPositions { + require.NoError(t, votes.SetBitAt(position, true)) } child.Block.Body.PayloadAttestations.Append(&cltypes.PayloadAttestation{ AggregationBits: votes, @@ -133,14 +203,7 @@ func TestOnBlockFromForwardSyncAppliesEmbeddedPtcVotes(t *testing.T) { }) require.NoError(t, store.OnBlock(context.Background(), child, false, false, false)) - require.True(t, store.payloadTimeliness(anchorRoot, false)) - require.True(t, store.payloadDataAvailability(anchorRoot, false)) - require.False(t, store.ShouldBuildOnFull(ForkChoiceNode{Root: anchorRoot, PayloadStatus: cltypes.PayloadStatusFull}, 2)) - head, err := store.GetHeadNode() - require.NoError(t, err) - childRoot, err := child.Block.HashSSZ() - require.NoError(t, err) - require.Equal(t, ForkChoiceNode{Root: childRoot, PayloadStatus: cltypes.PayloadStatusEmpty}, head) + return store, anchorRoot, child } type headerOnlyAnchorForkGraph struct { diff --git a/cl/phase1/forkchoice/payload_vote.go b/cl/phase1/forkchoice/payload_vote.go index 112cb2bf43d..c5eb708b797 100644 --- a/cl/phase1/forkchoice/payload_vote.go +++ b/cl/phase1/forkchoice/payload_vote.go @@ -55,7 +55,8 @@ func (f *ForkChoiceStore) notifyPtcMessages( // Pre-compute PTC per unique blockRoot to avoid redundant state lookups // for every attesting validator (PtcSize can be 512). type cachedPTC struct { - ptc []uint64 + ptc []uint64 + positions map[uint64][]int } ptcCache := make(map[common.Hash]*cachedPTC) @@ -80,7 +81,19 @@ func (f *ForkChoiceStore) notifyPtcMessages( if err != nil { continue } - cached = &cachedPTC{ptc: ptc} + ptcSize := f.beaconCfg.PtcSize + if ptcSize == 0 { + ptcSize = clparams.MaxPtcSize + } + ptcSize = min(ptcSize, clparams.MaxPtcSize) + if uint64(len(ptc)) > ptcSize { + ptc = ptc[:int(ptcSize)] + } + positions := make(map[uint64][]int, len(ptc)) + for position, validatorIndex := range ptc { + positions[validatorIndex] = append(positions[validatorIndex], position) + } + cached = &cachedPTC{ptc: ptc, positions: positions} ptcCache[blockRoot] = cached } @@ -88,18 +101,25 @@ func (f *ForkChoiceStore) notifyPtcMessages( continue } - for j := range cached.ptc { - if payloadAttestation.AggregationBits.GetBitAt(j) { - f.applyPayloadAttestationVote(j, data, blockRoot) + selectedValidators := make(map[uint64]struct{}, len(cached.ptc)) + for position, validatorIndex := range cached.ptc { + if payloadAttestation.AggregationBits.GetBitAt(position) { + selectedValidators[validatorIndex] = struct{}{} } } + if len(selectedValidators) == 0 { + continue + } + ptcIndices := make([]int, 0, len(cached.ptc)) + for validatorIndex := range selectedValidators { + ptcIndices = append(ptcIndices, cached.positions[validatorIndex]...) + } + f.applyPayloadAttestationVotes(ptcIndices, data, blockRoot) } } -// applyPayloadAttestationVote updates PTC vote tracking for a single PTC position. -// ptcIndex is the position in the PTC (the aggregation bit index). -func (f *ForkChoiceStore) applyPayloadAttestationVote( - ptcIndex int, +func (f *ForkChoiceStore) applyPayloadAttestationVotes( + ptcIndices []int, data *cltypes.PayloadAttestationData, blockRoot common.Hash, ) { @@ -115,8 +135,10 @@ func (f *ForkChoiceStore) applyPayloadAttestationVote( if existing, ok := f.payloadDataAvailabilityVote.Load(blockRoot); ok { dataAvailabilityVotes = existing.([clparams.PtcSize]int8) } - timelinessVotes[ptcIndex] = boolToVote(data.PayloadPresent) - dataAvailabilityVotes[ptcIndex] = boolToVote(data.BlobDataAvailable) + for _, ptcIndex := range ptcIndices { + timelinessVotes[ptcIndex] = boolToVote(data.PayloadPresent) + dataAvailabilityVotes[ptcIndex] = boolToVote(data.BlobDataAvailable) + } f.payloadTimelinessVote.Store(blockRoot, timelinessVotes) f.payloadDataAvailabilityVote.Store(blockRoot, dataAvailabilityVotes) diff --git a/cl/phase1/network/beacon_downloader.go b/cl/phase1/network/beacon_downloader.go index dddc4868259..dbb64520e8d 100644 --- a/cl/phase1/network/beacon_downloader.go +++ b/cl/phase1/network/beacon_downloader.go @@ -62,7 +62,9 @@ type ForwardBeaconDownloader struct { httpFallbackURL string // beacon API base URL for HTTP fallback when P2P fails httpPreferred atomic.Bool // set after first HTTP fallback success; skips P2P probing - mu sync.Mutex + mu sync.Mutex + gloasLookahead *cltypes.SignedBeaconBlock + gloasNextUnscanned uint64 } func NewForwardBeaconDownloader(ctx context.Context, rpc *rpc.BeaconRpcP2P, beaconCfg *clparams.BeaconChainConfig) *ForwardBeaconDownloader { @@ -106,6 +108,9 @@ func (f *ForwardBeaconDownloader) SetMinSlot(slot uint64) { f.mu.Lock() defer f.mu.Unlock() f.minSlot = slot + if f.gloasLookahead != nil && f.gloasLookahead.Block.Slot < slot { + f.clearGloasScan() + } } // SetHighestProcessedSlot sets the highest processed slot so far. @@ -115,12 +120,18 @@ func (f *ForwardBeaconDownloader) SetHighestProcessedSlot(highestSlotProcessed u if highestSlotProcessed > f.highestSlotProcessed { f.highestSlotProcessed = highestSlotProcessed f.highestSlotUpdateTime = time.Now() + if f.gloasLookahead != nil && f.gloasLookahead.Block.Slot <= highestSlotProcessed { + f.clearGloasScan() + } } } type peerAndBlocks struct { - peerId string - blocks []*cltypes.SignedBeaconBlock + peerId string + blocks []*cltypes.SignedBeaconBlock + rangeStart uint64 + rangeCount uint64 + hadGloasPending bool } func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { @@ -130,11 +141,16 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { // Fast path: when HTTP has been working, skip P2P probing entirely. if f.httpPreferred.Load() && f.httpFallbackURL != "" { - httpStart := f.highestSlotProcessed + 1 - httpBlocks, httpErr := fetchBlocksFromBeaconAPI(ctx, f.httpFallbackURL, httpStart, count+10, f.beaconCfg) - if httpErr == nil && len(httpBlocks) > 0 { - atomicResp.Store(peerAndBlocks{"http-fallback", httpBlocks}) - } else { + httpStart, hadGloasPending := f.nextRequestStart(false) + httpCount := capRequestCount(httpStart, count+10) + httpBlocks, httpErr := fetchBlocksFromBeaconAPI(ctx, f.httpFallbackURL, httpStart, httpCount, f.beaconCfg) + switch { + case httpErr == nil && len(httpBlocks) > 0: + atomicResp.Store(peerAndBlocks{"http-fallback", httpBlocks, httpStart, httpCount, hadGloasPending}) + case httpErr == nil && hadGloasPending: + f.recordEmptyRange(httpStart, httpCount, true, "http-fallback") + return + default: // HTTP failed — fall back to P2P probing. f.httpPreferred.Store(false) } @@ -147,6 +163,41 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { // Start with a base interval; backoff increases it on repeated failures. baseInterval := 300 * time.Millisecond var consecutiveFailures atomic.Int32 + var requestsMu sync.Mutex + inFlightRequests := 0 + responseAccepted := false + type emptyRangeResult struct { + lastSlot uint64 + apply func() + } + var pendingEmpty *emptyRangeResult + beginRequest := func() bool { + requestsMu.Lock() + defer requestsMu.Unlock() + if responseAccepted || pendingEmpty != nil { + return false + } + inFlightRequests++ + return true + } + completeRequest := func(response *peerAndBlocks, empty *emptyRangeResult) { + requestsMu.Lock() + defer requestsMu.Unlock() + if response != nil && !responseAccepted { + responseAccepted = true + atomicResp.Store(*response) + } + if empty != nil && (pendingEmpty == nil || empty.lastSlot > pendingEmpty.lastSlot) { + pendingEmpty = empty + } + inFlightRequests-- + if inFlightRequests == 0 { + if !responseAccepted && pendingEmpty != nil { + pendingEmpty.apply() + } + pendingEmpty = nil + } + } reqInterval := time.NewTicker(baseInterval) defer reqInterval.Stop() // Timeout: if no blocks received within this duration, return to let the caller @@ -158,21 +209,21 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { for { select { case <-reqInterval.C: + if !beginRequest() { + continue + } go func() { + var acceptedResponse *peerAndBlocks + var emptyResponse *emptyRangeResult + defer func() { completeRequest(acceptedResponse, emptyResponse) }() if len(atomicResp.Load().(peerAndBlocks).blocks) > 0 { return } - var reqSlot uint64 - if f.highestSlotProcessed > 2 { - reqSlot = f.highestSlotProcessed - 2 - } - if reqSlot < f.minSlot { - reqSlot = f.minSlot - } + reqSlot, hadGloasPending := f.nextRequestStart(true) // Request one extra block beyond the batch for GLOAS lookahead: // the extra block lets determineFullGloasRoots check whether the // last batch block is FULL or EMPTY, instead of guessing FULL. - reqCount := count + 1 + reqCount := capRequestCount(reqSlot, count+1) // Cap the request at the next fork epoch boundary. The Eth2 spec // says peers SHOULD NOT serve blocks across fork boundaries in a @@ -206,14 +257,23 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { if len(atomicResp.Load().(peerAndBlocks).blocks) > 0 { return } - httpStart := f.highestSlotProcessed + 1 - httpBlocks, httpErr := fetchBlocksFromBeaconAPI(ctx, f.httpFallbackURL, httpStart, count+10, f.beaconCfg) + httpStart, httpHadGloasPending := f.nextRequestStart(false) + httpCount := capRequestCount(httpStart, count+10) + httpBlocks, httpErr := fetchBlocksFromBeaconAPI(ctx, f.httpFallbackURL, httpStart, httpCount, f.beaconCfg) if httpErr == nil && len(httpBlocks) > 0 { log.Debug("[ForwardBeaconDownloader] P2P failed, fetched blocks from beacon API", "fromSlot", httpStart, "count", len(httpBlocks)) consecutiveFailures.Store(0) f.httpPreferred.Store(true) - atomicResp.Store(peerAndBlocks{"http-fallback", httpBlocks}) + response := peerAndBlocks{"http-fallback", httpBlocks, httpStart, httpCount, httpHadGloasPending} + acceptedResponse = &response + return + } + if httpErr == nil && httpHadGloasPending { + emptyResponse = &emptyRangeResult{ + lastSlot: lastSlotInRange(httpStart, httpCount), + apply: func() { f.recordEmptyRange(httpStart, httpCount, true, "http-fallback") }, + } return } if httpErr != nil { @@ -231,29 +291,17 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { if len(responses) == 0 { // Empty response: no blocks in this slot range. // Advance past the requested range so we don't get stuck requesting the same empty range. - if reqCount == 0 { - return - } - newSlot := reqSlot + reqCount - 1 - if reqCount-1 > math.MaxUint64-reqSlot { - newSlot = math.MaxUint64 + emptyResponse = &emptyRangeResult{ + lastSlot: lastSlotInRange(reqSlot, reqCount), + apply: func() { f.recordEmptyRange(reqSlot, reqCount, hadGloasPending, peerId) }, } - f.mu.Lock() - if newSlot > f.highestSlotProcessed { - log.Debug("Empty block range response, advancing past gap", "from", f.highestSlotProcessed, "to", newSlot, "peer", peerId) - f.highestSlotProcessed = newSlot - f.highestSlotUpdateTime = time.Now() - } - f.mu.Unlock() return } // Success: reset backoff consecutiveFailures.Store(0) reqInterval.Reset(baseInterval) - if len(atomicResp.Load().(peerAndBlocks).blocks) > 0 { - return - } - atomicResp.Store(peerAndBlocks{peerId, responses}) + response := peerAndBlocks{peerId, responses, reqSlot, reqCount, hadGloasPending} + acceptedResponse = &response }() case <-ctx.Done(): return @@ -273,6 +321,12 @@ Process: resp := atomicResp.Load().(peerAndBlocks) processBlocks := resp.blocks pid := resp.peerId + f.mu.Lock() + lookahead := f.gloasLookahead + f.mu.Unlock() + if lookahead != nil { + processBlocks = mergeGloasLookahead(processBlocks, lookahead) + } slices.SortFunc(processBlocks, func(a, b *cltypes.SignedBeaconBlock) int { return cmp.Compare(a.Block.Slot, b.Block.Slot) @@ -287,6 +341,8 @@ Process: // last batch block's FULL/EMPTY status accurately. Use all blocks for determination, // then trim to `count` before processing. var envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope + var nextGloasLookahead *cltypes.SignedBeaconBlock + nextGloasCursor := nextSlotAfterRange(resp.rangeStart, resp.rangeCount) if anyGloasBlock(processBlocks) { // Always keep at least 1 block as lookahead so the last processed // block's FULL/EMPTY status is determined from the actual next block @@ -294,6 +350,10 @@ Process: // batch boundary has its envelope skipped, and the next batch's first // block fails with ErrParentEnvelopePending. processCount := min(int(count), len(processBlocks)-1) + nextGloasLookahead = processBlocks[processCount] + if processCount+1 < len(processBlocks) { + nextGloasCursor = saturatingIncrement(nextGloasLookahead.Block.Slot) + } fullRoots := determineFullGloasRoots(processBlocks, processCount) processBlocks = processBlocks[:processCount] if len(fullRoots) > 0 { @@ -331,6 +391,8 @@ Process: if len(retained) < len(processBlocks) { log.Debug("[ForwardBeaconDownloader] retaining frontier before missing GLOAS envelope", "retainedBlocks", len(retained), "batchBlocks", len(processBlocks)) + nextGloasLookahead = processBlocks[len(retained)] + nextGloasCursor = saturatingIncrement(nextGloasLookahead.Block.Slot) processBlocks = retained } } @@ -345,6 +407,10 @@ Process: var highestSlotProcessed uint64 var err error if highestSlotProcessed, err = f.process(f.highestSlotProcessed, processBlocks, envelopes); err != nil { + if len(processBlocks) > 0 { + f.gloasLookahead = processBlocks[0] + f.gloasNextUnscanned = saturatingIncrement(processBlocks[0].Block.Slot) + } if shouldBanProcessPeer(pid, err) { f.rpc.BanPeer(pid) } @@ -354,6 +420,103 @@ Process: f.highestSlotProcessed = highestSlotProcessed f.highestSlotUpdateTime = time.Now() } + if nextGloasLookahead != nil { + for _, block := range processBlocks { + if block.Block.Slot > f.highestSlotProcessed { + nextGloasLookahead = block + nextGloasCursor = saturatingIncrement(block.Block.Slot) + break + } + } + } + if nextGloasLookahead != nil && nextGloasLookahead.Block.Slot > f.highestSlotProcessed { + f.gloasLookahead = nextGloasLookahead + f.gloasNextUnscanned = max(nextGloasCursor, saturatingIncrement(nextGloasLookahead.Block.Slot)) + } else { + f.clearGloasScan() + } +} + +func (f *ForwardBeaconDownloader) nextRequestStart(overlap bool) (uint64, bool) { + f.mu.Lock() + defer f.mu.Unlock() + if f.gloasLookahead != nil { + return f.gloasNextUnscanned, true + } + start := saturatingIncrement(f.highestSlotProcessed) + if overlap && f.highestSlotProcessed > 2 { + start = f.highestSlotProcessed - 2 + } + if start < f.minSlot { + start = f.minSlot + } + return start, false +} + +func (f *ForwardBeaconDownloader) recordEmptyRange(start, count uint64, hadGloasPending bool, peerID string) { + if count == 0 { + return + } + lastSlot := lastSlotInRange(start, count) + f.mu.Lock() + defer f.mu.Unlock() + if hadGloasPending || f.gloasLookahead != nil { + if f.gloasLookahead != nil { + f.gloasNextUnscanned = max(f.gloasNextUnscanned, saturatingIncrement(lastSlot)) + } + return + } + if lastSlot > f.highestSlotProcessed { + log.Debug("Empty block range response, advancing past gap", "from", f.highestSlotProcessed, "to", lastSlot, "peer", peerID) + f.highestSlotProcessed = lastSlot + f.highestSlotUpdateTime = time.Now() + } +} + +func (f *ForwardBeaconDownloader) clearGloasScan() { + f.gloasLookahead = nil + f.gloasNextUnscanned = 0 +} + +func mergeGloasLookahead(blocks []*cltypes.SignedBeaconBlock, lookahead *cltypes.SignedBeaconBlock) []*cltypes.SignedBeaconBlock { + lookaheadRoot, lookaheadErr := lookahead.Block.HashSSZ() + merged := make([]*cltypes.SignedBeaconBlock, 0, len(blocks)+1) + merged = append(merged, lookahead) + for _, block := range blocks { + if lookaheadErr == nil { + root, err := block.Block.HashSSZ() + if err == nil && root == lookaheadRoot { + continue + } + } + merged = append(merged, block) + } + return merged +} + +func capRequestCount(start, count uint64) uint64 { + if count == 0 || count-1 <= math.MaxUint64-start { + return count + } + return math.MaxUint64 - start + 1 +} + +func lastSlotInRange(start, count uint64) uint64 { + if count == 0 { + return start + } + return start + capRequestCount(start, count) - 1 +} + +func nextSlotAfterRange(start, count uint64) uint64 { + return saturatingIncrement(lastSlotInRange(start, count)) +} + +func saturatingIncrement(slot uint64) uint64 { + if slot == math.MaxUint64 { + return slot + } + return slot + 1 } func retainBlocksBeforeMissingGloasEnvelope(blocks []*cltypes.SignedBeaconBlock, fullRoots [][32]byte, envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) []*cltypes.SignedBeaconBlock { @@ -436,6 +599,7 @@ func determineFullGloasRoots(blocks []*cltypes.SignedBeaconBlock, processCount i // boundary instead of capping — otherwise the downloader re-requests the same // already-processed slots and never makes progress. func (f *ForwardBeaconDownloader) capAtForkBoundary(reqSlot, reqCount uint64) (uint64, uint64) { + reqCount = capRequestCount(reqSlot, reqCount) slotsPerEpoch := f.beaconCfg.SlotsPerEpoch forkEpochs := []uint64{ f.beaconCfg.AltairForkEpoch, @@ -456,27 +620,26 @@ func (f *ForwardBeaconDownloader) capAtForkBoundary(reqSlot, reqCount uint64) (u } slices.Sort(boundaries) - endSlot := reqSlot + reqCount for _, boundarySlot := range boundaries { if boundarySlot <= reqSlot { continue } - if boundarySlot >= endSlot { + distance := boundarySlot - reqSlot + if distance >= reqCount { break } - // boundarySlot is in (reqSlot, endSlot). - if boundarySlot <= f.highestSlotProcessed+1 { + if boundarySlot <= saturatingIncrement(f.highestSlotProcessed) { // Already processed past this boundary — skip the pre-boundary // overlap and start from the boundary so the request stays // within a single fork. reqSlot = boundarySlot + reqCount -= distance } else { // Haven't reached this boundary yet — cap the request here. - reqCount = boundarySlot - reqSlot + reqCount = distance return reqSlot, reqCount } } - reqCount = endSlot - reqSlot return reqSlot, reqCount } diff --git a/cl/phase1/network/beacon_downloader_test.go b/cl/phase1/network/beacon_downloader_test.go index 30f0851ddd4..152d1587dfd 100644 --- a/cl/phase1/network/beacon_downloader_test.go +++ b/cl/phase1/network/beacon_downloader_test.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "math" "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -167,6 +169,350 @@ func TestForwardBeaconDownloaderRetainsSingleHTTPGloasBlockUntilLookahead(t *tes require.Equal(t, uint64(9), downloader.GetHighestProcessedSlot()) } +func TestForwardBeaconDownloaderScansPastGloasLookaheadGap(t *testing.T) { + const frontier = uint64(100) + first := makeGloasBlock(frontier+1, hash(0xaa), common.Hash{}) + successor := makeGloasBlock(frontier+66, hash(0xbb), hash(0xcc)) + + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 3) + allowSuccessor := make(chan struct{}) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(_ context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + switch requestCount.Add(1) { + case 1: + return []*cltypes.SignedBeaconBlock{first}, "block-peer", nil + case 2: + return []*cltypes.SignedBeaconBlock{}, "block-peer", nil + case 3: + <-allowSuccessor + return []*cltypes.SignedBeaconBlock{successor}, "block-peer", nil + default: + return nil, "block-peer", errors.New("unexpected request") + } + }, + } + downloader.SetHighestProcessedSlot(frontier) + var processedSlots []uint64 + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + for _, block := range blocks { + processedSlots = append(processedSlots, block.Block.Slot) + highest = block.Block.Slot + } + return highest, nil + }) + + downloader.RequestMore(t.Context()) + firstRequest := <-requests + require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier - 2, Count: 33}, firstRequest) + require.Empty(t, processedSlots) + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(t.Context()) + }() + emptyRequest := <-requests + require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier + 31, Count: 33}, emptyRequest) + farRequest := <-requests + require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier + 64, Count: 33}, farRequest) + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + close(allowSuccessor) + <-done + require.Equal(t, []uint64{frontier + 1}, processedSlots) + require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot()) +} + +func TestForwardBeaconDownloaderHTTPScansPastGloasLookaheadGap(t *testing.T) { + const frontier = uint64(100) + first := makeGloasBlock(frontier+1, hash(0xaa), common.Hash{}) + successor := makeGloasBlock(frontier+45, hash(0xbb), hash(0xcc)) + firstEncoded, err := first.EncodeSSZ(nil) + require.NoError(t, err) + successorEncoded, err := successor.EncodeSSZ(nil) + require.NoError(t, err) + + requestedSlots := make(chan uint64, 84) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var slot uint64 + _, _ = fmt.Sscanf(r.URL.Path, "/eth/v2/beacon/blocks/%d", &slot) + requestedSlots <- slot + w.Header().Set("Eth-Consensus-Version", "gloas") + switch slot { + case first.Block.Slot: + _, _ = w.Write(firstEncoded) + case successor.Block.Slot: + _, _ = w.Write(successorEncoded) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig) + downloader.SetHighestProcessedSlot(frontier) + downloader.SetHTTPFallbackURL(server.URL) + downloader.httpPreferred.Store(true) + var processedSlots []uint64 + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + for _, block := range blocks { + processedSlots = append(processedSlots, block.Block.Slot) + highest = block.Block.Slot + } + return highest, nil + }) + + downloader.RequestMore(t.Context()) + firstRange := make([]uint64, 42) + for i := range firstRange { + firstRange[i] = <-requestedSlots + } + require.Contains(t, firstRange, frontier+1) + require.Contains(t, firstRange, frontier+42) + require.Empty(t, processedSlots) + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + + downloader.RequestMore(t.Context()) + secondRange := make([]uint64, 42) + for i := range secondRange { + secondRange[i] = <-requestedSlots + } + require.Contains(t, secondRange, frontier+43) + require.NotContains(t, secondRange, frontier+1) + require.Equal(t, []uint64{frontier + 1}, processedSlots) + require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot()) +} + +func TestForwardBeaconDownloaderRetainsGloasScanAcrossDuplicateAndCancellation(t *testing.T) { + const frontier = uint64(100) + first := makeGloasBlock(frontier+1, hash(0xaa), common.Hash{}) + successor := makeGloasBlock(frontier+66, hash(0xbb), hash(0xcc)) + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 4) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + switch requestCount.Add(1) { + case 1: + return []*cltypes.SignedBeaconBlock{first}, "block-peer", nil + case 2: + return []*cltypes.SignedBeaconBlock{first}, "block-peer", nil + case 3: + <-ctx.Done() + return nil, "block-peer", ctx.Err() + case 4: + return []*cltypes.SignedBeaconBlock{successor}, "block-peer", nil + default: + return nil, "block-peer", errors.New("unexpected request") + } + }, + } + downloader.SetHighestProcessedSlot(frontier) + var processedSlots []uint64 + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + for _, block := range blocks { + processedSlots = append(processedSlots, block.Block.Slot) + highest = block.Block.Slot + } + return highest, nil + }) + + downloader.RequestMore(t.Context()) + require.Equal(t, frontier-2, (<-requests).StartSlot) + downloader.RequestMore(t.Context()) + require.Equal(t, frontier+31, (<-requests).StartSlot) + require.Empty(t, processedSlots) + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + + cancelCtx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(cancelCtx) + }() + canceledRequest := <-requests + cancel() + <-done + require.Equal(t, frontier+64, canceledRequest.StartSlot) + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + + downloader.RequestMore(t.Context()) + retryRequest := <-requests + require.Equal(t, canceledRequest, retryRequest) + require.Equal(t, []uint64{frontier + 1}, processedSlots) + require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot()) +} + +func TestForwardBeaconDownloaderPendingGloasScanDoesNotWrapAtMaxSlot(t *testing.T) { + frontier := uint64(math.MaxUint64 - 1) + last := makeGloasBlock(math.MaxUint64, hash(0xaa), common.Hash{}) + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + if requestCount.Add(1) == 1 { + return []*cltypes.SignedBeaconBlock{last}, "block-peer", nil + } + <-ctx.Done() + return nil, "block-peer", ctx.Err() + }, + } + downloader.SetHighestProcessedSlot(frontier) + downloader.SetProcessFunction(func(highest uint64, _ []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + return highest, nil + }) + + downloader.RequestMore(t.Context()) + firstRequest := <-requests + require.Equal(t, frontier-2, firstRequest.StartSlot) + require.Equal(t, uint64(4), firstRequest.Count) + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(ctx) + }() + secondRequest := <-requests + cancel() + <-done + require.Equal(t, uint64(math.MaxUint64), secondRequest.StartSlot) + require.Equal(t, uint64(1), secondRequest.Count) + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) +} + +func TestForwardBeaconDownloaderRetainsUnprocessedGloasSuffix(t *testing.T) { + const frontier = uint64(100) + first := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{}) + second := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1)) + third := makeGloasBlock(frontier+3, hash(0xa3), hash(0xb2)) + fourth := makeGloasBlock(frontier+4, hash(0xa4), hash(0xb3)) + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(_ context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + if requestCount.Add(1) == 1 { + return []*cltypes.SignedBeaconBlock{first, second, third}, "block-peer", nil + } + return []*cltypes.SignedBeaconBlock{third, fourth}, "block-peer", nil + }, + } + downloader.SetHighestProcessedSlot(frontier) + var processedBatches [][]uint64 + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + batch := make([]uint64, len(blocks)) + for i, block := range blocks { + batch[i] = block.Block.Slot + } + processedBatches = append(processedBatches, batch) + if len(processedBatches) == 1 { + return first.Block.Slot, nil + } + return blocks[len(blocks)-1].Block.Slot, nil + }) + + downloader.RequestMore(t.Context()) + <-requests + require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot()) + require.Equal(t, [][]uint64{{frontier + 1, frontier + 2}}, processedBatches) + + downloader.RequestMore(t.Context()) + secondRequest := <-requests + require.Equal(t, frontier+3, secondRequest.StartSlot) + require.Equal(t, [][]uint64{{frontier + 1, frontier + 2}, {frontier + 2, frontier + 3}}, processedBatches) + require.Equal(t, frontier+3, downloader.GetHighestProcessedSlot()) +} + +func TestForwardBeaconDownloaderOverlappingEmptyDoesNotSkipGloasLookahead(t *testing.T) { + block := makeGloasBlock(101, hash(0xaa), common.Hash{}) + firstStarted := make(chan struct{}) + secondReturned := make(chan struct{}) + releaseFirst := make(chan struct{}) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(_ context.Context, _, _ uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + switch requestCount.Add(1) { + case 1: + close(firstStarted) + <-releaseFirst + return []*cltypes.SignedBeaconBlock{block}, "block-peer", nil + case 2: + close(secondReturned) + return []*cltypes.SignedBeaconBlock{}, "block-peer", nil + default: + return nil, "block-peer", errors.New("unexpected request") + } + }, + } + downloader.SetHighestProcessedSlot(100) + downloader.SetProcessFunction(func(highest uint64, _ []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + return highest, nil + }) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(t.Context()) + }() + + <-firstStarted + <-secondReturned + require.Equal(t, uint64(100), downloader.GetHighestProcessedSlot()) + close(releaseFirst) + <-done + require.Equal(t, uint64(100), downloader.GetHighestProcessedSlot()) +} + +func TestForwardBeaconDownloaderAppliesPendingEmptyAfterOverlappingError(t *testing.T) { + firstStarted := make(chan struct{}) + emptyReturned := make(chan struct{}) + releaseError := make(chan struct{}) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(ctx context.Context, _, _ uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + switch requestCount.Add(1) { + case 1: + close(firstStarted) + <-releaseError + return nil, "block-peer", errors.New("request failed") + case 2: + close(emptyReturned) + return []*cltypes.SignedBeaconBlock{}, "block-peer", nil + default: + <-ctx.Done() + return nil, "block-peer", ctx.Err() + } + }, + } + downloader.SetHighestProcessedSlot(100) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(ctx) + }() + + <-firstStarted + <-emptyReturned + require.Equal(t, uint64(100), downloader.GetHighestProcessedSlot()) + close(releaseError) + require.Eventually(t, func() bool { + return downloader.GetHighestProcessedSlot() == 130 + }, time.Second, 10*time.Millisecond) + cancel() + <-done +} + func TestRetainBlocksBeforeMissingGloasEnvelopeKeepsCompletePrefix(t *testing.T) { first := makeGloasBlock(10, hash(0xaa), common.Hash{}) second := makeGloasBlock(11, hash(0xbb), hash(0xaa)) From 6c8a1065c1d1a1843d2d775754692a6d59de0b02 Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 22:58:07 +0800 Subject: [PATCH 36/43] cl: validate Gloas retry ownership --- .../forkchoice/fork_graph/fork_graph_disk.go | 6 +- .../forkchoice/fork_graph/fork_graph_test.go | 15 +- cl/phase1/network/beacon_downloader.go | 34 ++- cl/phase1/network/beacon_downloader_test.go | 255 ++++++++++++++++++ 4 files changed, 293 insertions(+), 17 deletions(-) diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go b/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go index 98709517ad4..3b65f0bd3f2 100644 --- a/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go +++ b/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go @@ -464,13 +464,13 @@ func (f *forkGraphDisk) HasBlockEquivocation(slot, proposerIndex uint64, exceptR return false } found := false - f.blocks.Range(func(key, value any) bool { + f.headers.Range(func(key, value any) bool { root, ok := key.(common.Hash) if !ok || root == exceptRoot { return true } - block, ok := value.(*cltypes.SignedBeaconBlock) - if ok && block != nil && block.Block != nil && block.Block.Slot == slot && block.Block.ProposerIndex == proposerIndex { + header, ok := value.(*cltypes.BeaconBlockHeader) + if ok && header != nil && header.Slot == slot && header.ProposerIndex == proposerIndex { found = true return false } diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_test.go b/cl/phase1/forkchoice/fork_graph/fork_graph_test.go index 1b9d813c22e..66dc325e205 100644 --- a/cl/phase1/forkchoice/fork_graph/fork_graph_test.go +++ b/cl/phase1/forkchoice/fork_graph/fork_graph_test.go @@ -505,7 +505,7 @@ func TestHasBlockChildAtOrAfterUsesValidatedChildren(t *testing.T) { require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 64)) } -func TestHasBlockEquivocation(t *testing.T) { +func TestHasBlockEquivocationUsesValidatedHeaders(t *testing.T) { f := &forkGraphDisk{} root := common.Hash{1} block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) @@ -513,21 +513,18 @@ func TestHasBlockEquivocation(t *testing.T) { block.Block.ProposerIndex = 9 f.blocks.Store(root, block) + require.False(t, f.HasBlockEquivocation(64, 9, common.Hash{2})) + f.headers.Store(root, &cltypes.BeaconBlockHeader{Slot: 64, ProposerIndex: 9}) require.True(t, f.HasBlockEquivocation(64, 9, common.Hash{2})) require.False(t, f.HasBlockEquivocation(64, 9, root)) require.False(t, f.HasBlockEquivocation(64, 8, common.Hash{2})) + require.False(t, f.HasBlockEquivocation(65, 9, common.Hash{2})) } func TestHasBlockEquivocationUsesPruneBoundary(t *testing.T) { f := &forkGraphDisk{} - retained := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) - retained.Block.Slot = 64 - retained.Block.ProposerIndex = 9 - pruned := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) - pruned.Block.Slot = 63 - pruned.Block.ProposerIndex = 9 - f.blocks.Store(common.Hash{1}, retained) - f.blocks.Store(common.Hash{2}, pruned) + f.headers.Store(common.Hash{1}, &cltypes.BeaconBlockHeader{Slot: 64, ProposerIndex: 9}) + f.headers.Store(common.Hash{2}, &cltypes.BeaconBlockHeader{Slot: 63, ProposerIndex: 9}) f.lowestAvailableBlock.Store(65) require.True(t, f.HasBlockEquivocation(64, 9, common.Hash{3})) diff --git a/cl/phase1/network/beacon_downloader.go b/cl/phase1/network/beacon_downloader.go index dbb64520e8d..6b6eaf264a9 100644 --- a/cl/phase1/network/beacon_downloader.go +++ b/cl/phase1/network/beacon_downloader.go @@ -331,6 +331,15 @@ Process: slices.SortFunc(processBlocks, func(a, b *cltypes.SignedBeaconBlock) int { return cmp.Compare(a.Block.Slot, b.Block.Slot) }) + hasGloasBlocks := anyGloasBlock(processBlocks) + if hasGloasBlocks && !connectedGloasBlocks(processBlocks) { + f.mu.Lock() + if lookahead != nil && f.gloasLookahead == lookahead { + f.clearGloasScan() + } + f.mu.Unlock() + return + } // For GLOAS blocks, fetch envelopes only for FULL blocks (whose payload was delivered). // EMPTY blocks never have envelopes on the network, so requesting them causes a 30s stall. @@ -343,7 +352,7 @@ Process: var envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope var nextGloasLookahead *cltypes.SignedBeaconBlock nextGloasCursor := nextSlotAfterRange(resp.rangeStart, resp.rangeCount) - if anyGloasBlock(processBlocks) { + if hasGloasBlocks { // Always keep at least 1 block as lookahead so the last processed // block's FULL/EMPTY status is determined from the actual next block // rather than guessed as EMPTY. Without this, a FULL block at the @@ -407,15 +416,20 @@ Process: var highestSlotProcessed uint64 var err error if highestSlotProcessed, err = f.process(f.highestSlotProcessed, processBlocks, envelopes); err != nil { - if len(processBlocks) > 0 { - f.gloasLookahead = processBlocks[0] - f.gloasNextUnscanned = saturatingIncrement(processBlocks[0].Block.Slot) + if lookahead != nil && f.gloasLookahead == lookahead { + f.clearGloasScan() } - if shouldBanProcessPeer(pid, err) { + if lookahead == nil && shouldBanProcessPeer(pid, err) { f.rpc.BanPeer(pid) } return } + if len(processBlocks) > 0 && nextGloasLookahead != nil && highestSlotProcessed <= f.highestSlotProcessed { + if lookahead != nil && f.gloasLookahead == lookahead { + f.clearGloasScan() + } + return + } if highestSlotProcessed > f.highestSlotProcessed { f.highestSlotProcessed = highestSlotProcessed f.highestSlotUpdateTime = time.Now() @@ -494,6 +508,16 @@ func mergeGloasLookahead(blocks []*cltypes.SignedBeaconBlock, lookahead *cltypes return merged } +func connectedGloasBlocks(blocks []*cltypes.SignedBeaconBlock) bool { + for i := 1; i < len(blocks); i++ { + root, err := blocks[i-1].Block.HashSSZ() + if err != nil || blocks[i].Block.ParentRoot != root { + return false + } + } + return true +} + func capRequestCount(start, count uint64) uint64 { if count == 0 || count-1 <= math.MaxUint64-start { return count diff --git a/cl/phase1/network/beacon_downloader_test.go b/cl/phase1/network/beacon_downloader_test.go index 152d1587dfd..614dee36dc5 100644 --- a/cl/phase1/network/beacon_downloader_test.go +++ b/cl/phase1/network/beacon_downloader_test.go @@ -19,6 +19,15 @@ import ( "github.com/erigontech/erigon/common" ) +func linkBeaconBlocks(t *testing.T, blocks ...*cltypes.SignedBeaconBlock) { + t.Helper() + for i := 1; i < len(blocks); i++ { + root, err := blocks[i-1].Block.HashSSZ() + require.NoError(t, err) + blocks[i].Block.ParentRoot = root + } +} + func TestForwardBeaconDownloaderEmptyCappedRangeStopsAtForkBoundary(t *testing.T) { cfg := clparams.MainnetBeaconConfig cfg.SlotsPerEpoch = 10 @@ -99,6 +108,7 @@ func TestShouldBanProcessPeer(t *testing.T) { func TestForwardBeaconDownloaderRetainsFrontierWhenHTTPFullEnvelopeIsMissing(t *testing.T) { first := makeGloasBlock(10, hash(0xaa), common.Hash{}) lookahead := makeGloasBlock(11, hash(0xbb), hash(0xaa)) + linkBeaconBlocks(t, first, lookahead) firstEncoded, err := first.EncodeSSZ(nil) require.NoError(t, err) lookaheadEncoded, err := lookahead.EncodeSSZ(nil) @@ -173,6 +183,7 @@ func TestForwardBeaconDownloaderScansPastGloasLookaheadGap(t *testing.T) { const frontier = uint64(100) first := makeGloasBlock(frontier+1, hash(0xaa), common.Hash{}) successor := makeGloasBlock(frontier+66, hash(0xbb), hash(0xcc)) + linkBeaconBlocks(t, first, successor) requests := make(chan cltypes.BeaconBlocksByRangeRequest, 3) allowSuccessor := make(chan struct{}) @@ -230,6 +241,7 @@ func TestForwardBeaconDownloaderHTTPScansPastGloasLookaheadGap(t *testing.T) { const frontier = uint64(100) first := makeGloasBlock(frontier+1, hash(0xaa), common.Hash{}) successor := makeGloasBlock(frontier+45, hash(0xbb), hash(0xcc)) + linkBeaconBlocks(t, first, successor) firstEncoded, err := first.EncodeSSZ(nil) require.NoError(t, err) successorEncoded, err := successor.EncodeSSZ(nil) @@ -290,6 +302,7 @@ func TestForwardBeaconDownloaderRetainsGloasScanAcrossDuplicateAndCancellation(t const frontier = uint64(100) first := makeGloasBlock(frontier+1, hash(0xaa), common.Hash{}) successor := makeGloasBlock(frontier+66, hash(0xbb), hash(0xcc)) + linkBeaconBlocks(t, first, successor) requests := make(chan cltypes.BeaconBlocksByRangeRequest, 4) var requestCount atomic.Int32 downloader := &ForwardBeaconDownloader{ @@ -394,6 +407,7 @@ func TestForwardBeaconDownloaderRetainsUnprocessedGloasSuffix(t *testing.T) { second := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1)) third := makeGloasBlock(frontier+3, hash(0xa3), hash(0xb2)) fourth := makeGloasBlock(frontier+4, hash(0xa4), hash(0xb3)) + linkBeaconBlocks(t, first, second, third, fourth) requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2) var requestCount atomic.Int32 downloader := &ForwardBeaconDownloader{ @@ -432,6 +446,247 @@ func TestForwardBeaconDownloaderRetainsUnprocessedGloasSuffix(t *testing.T) { require.Equal(t, frontier+3, downloader.GetHighestProcessedSlot()) } +func TestForwardBeaconDownloaderMixedOwnerProcessErrorRestartsOverlap(t *testing.T) { + const frontier = uint64(100) + cached := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{}) + successor := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1)) + cachedRoot, err := cached.Block.HashSSZ() + require.NoError(t, err) + successor.Block.ParentRoot = cachedRoot + + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 3) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + switch requestCount.Add(1) { + case 1: + return []*cltypes.SignedBeaconBlock{cached}, "peer-a", nil + case 2: + return []*cltypes.SignedBeaconBlock{successor}, "peer-b", nil + default: + <-ctx.Done() + return nil, "peer-c", ctx.Err() + } + }, + } + downloader.SetHighestProcessedSlot(frontier) + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + if len(blocks) == 0 { + return highest, nil + } + return highest, errors.New("cached block failed") + }) + + downloader.RequestMore(t.Context()) + <-requests + downloader.RequestMore(t.Context()) + <-requests + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(ctx) + }() + retry := <-requests + cancel() + <-done + require.Equal(t, frontier-2, retry.StartSlot) +} + +func TestForwardBeaconDownloaderSingleOwnerProcessErrorRestartsOverlap(t *testing.T) { + const frontier = uint64(100) + first := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{}) + successor := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1)) + firstRoot, err := first.Block.HashSSZ() + require.NoError(t, err) + successor.Block.ParentRoot = firstRoot + + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + if requestCount.Add(1) == 1 { + return []*cltypes.SignedBeaconBlock{first, successor}, "peer-a", nil + } + <-ctx.Done() + return nil, "peer-b", ctx.Err() + }, + } + downloader.SetHighestProcessedSlot(frontier) + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + if len(blocks) == 0 { + return highest, nil + } + return highest, fmt.Errorf("%w: state gap", ErrUnattributableProcess) + }) + + downloader.RequestMore(t.Context()) + <-requests + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(ctx) + }() + retry := <-requests + cancel() + <-done + require.Equal(t, frontier-2, retry.StartSlot) +} + +func TestForwardBeaconDownloaderNoProgressRestartsOverlap(t *testing.T) { + const frontier = uint64(100) + first := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{}) + successor := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1)) + firstRoot, err := first.Block.HashSSZ() + require.NoError(t, err) + successor.Block.ParentRoot = firstRoot + + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(_ context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + return []*cltypes.SignedBeaconBlock{first, successor}, fmt.Sprintf("peer-%d", requestCount.Add(1)), nil + }, + } + downloader.SetHighestProcessedSlot(frontier) + var processCount atomic.Int32 + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + if len(blocks) == 0 || processCount.Add(1) == 1 { + return highest, nil + } + return blocks[len(blocks)-1].Block.Slot, nil + }) + + downloader.RequestMore(t.Context()) + firstRequest := <-requests + require.Equal(t, frontier-2, firstRequest.StartSlot) + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + + downloader.RequestMore(t.Context()) + secondRequest := <-requests + require.Equal(t, frontier-2, secondRequest.StartSlot) + require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot()) +} + +func TestForwardBeaconDownloaderRejectsDisconnectedGloasLookahead(t *testing.T) { + const frontier = uint64(100) + cached := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{}) + disconnected := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1)) + disconnected.Block.ParentRoot = hash(0xff) + + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 3) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + switch requestCount.Add(1) { + case 1: + return []*cltypes.SignedBeaconBlock{cached}, "peer-a", nil + case 2: + return []*cltypes.SignedBeaconBlock{disconnected}, "peer-b", nil + default: + <-ctx.Done() + return nil, "peer-c", ctx.Err() + } + }, + } + downloader.SetHighestProcessedSlot(frontier) + var processed atomic.Int32 + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + processed.Add(int32(len(blocks))) + if len(blocks) == 0 { + return highest, nil + } + return blocks[len(blocks)-1].Block.Slot, nil + }) + + downloader.RequestMore(t.Context()) + <-requests + downloader.RequestMore(t.Context()) + <-requests + require.Zero(t, processed.Load()) + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(ctx) + }() + retry := <-requests + cancel() + <-done + require.Equal(t, frontier-2, retry.StartSlot) +} + +func TestForwardBeaconDownloaderRejectsDisconnectedGloasResponseSuffix(t *testing.T) { + const frontier = uint64(100) + cached := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{}) + linked := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1)) + disconnected := makeGloasBlock(frontier+3, hash(0xa3), hash(0xb2)) + cachedRoot, err := cached.Block.HashSSZ() + require.NoError(t, err) + linked.Block.ParentRoot = cachedRoot + disconnected.Block.ParentRoot = hash(0xff) + + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 3) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + switch requestCount.Add(1) { + case 1: + return []*cltypes.SignedBeaconBlock{cached}, "peer-a", nil + case 2: + return []*cltypes.SignedBeaconBlock{linked, disconnected}, "peer-b", nil + default: + <-ctx.Done() + return nil, "peer-c", ctx.Err() + } + }, + } + downloader.SetHighestProcessedSlot(frontier) + var processed atomic.Int32 + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + processed.Add(int32(len(blocks))) + if len(blocks) == 0 { + return highest, nil + } + return blocks[len(blocks)-1].Block.Slot, nil + }) + + downloader.RequestMore(t.Context()) + <-requests + downloader.RequestMore(t.Context()) + <-requests + require.Zero(t, processed.Load()) + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(ctx) + }() + retry := <-requests + cancel() + <-done + require.Equal(t, frontier-2, retry.StartSlot) +} + func TestForwardBeaconDownloaderOverlappingEmptyDoesNotSkipGloasLookahead(t *testing.T) { block := makeGloasBlock(101, hash(0xaa), common.Hash{}) firstStarted := make(chan struct{}) From 2f70ae1211227f17c5deef96cd480161f3cfa4ca Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 23:15:32 +0800 Subject: [PATCH 37/43] cl/network: preserve Gloas response ownership --- cl/phase1/network/beacon_downloader.go | 41 ++++++- cl/phase1/network/beacon_downloader_test.go | 116 +++++++++++++++++++- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/cl/phase1/network/beacon_downloader.go b/cl/phase1/network/beacon_downloader.go index 6b6eaf264a9..48fb6839eee 100644 --- a/cl/phase1/network/beacon_downloader.go +++ b/cl/phase1/network/beacon_downloader.go @@ -57,6 +57,7 @@ type ForwardBeaconDownloader struct { minSlot uint64 // earliest requestable slot (e.g. checkpoint anchor) rpc *rpc.BeaconRpcP2P requestBlocksByRange func(context.Context, uint64, uint64) ([]*cltypes.SignedBeaconBlock, string, error) + banPeer func(string) process ProcessFn beaconCfg *clparams.BeaconChainConfig httpFallbackURL string // beacon API base URL for HTTP fallback when P2P fails @@ -75,6 +76,7 @@ func NewForwardBeaconDownloader(ctx context.Context, rpc *rpc.BeaconRpcP2P, beac } if rpc != nil { f.requestBlocksByRange = rpc.SendBeaconBlocksByRangeReq + f.banPeer = rpc.BanPeer } return f } @@ -321,6 +323,13 @@ Process: resp := atomicResp.Load().(peerAndBlocks) processBlocks := resp.blocks pid := resp.peerId + slices.SortFunc(processBlocks, func(a, b *cltypes.SignedBeaconBlock) int { + return cmp.Compare(a.Block.Slot, b.Block.Slot) + }) + if anyGloasBlock(processBlocks) && !connectedGloasBlocks(processBlocks) { + f.rejectInvalidGloasResponse(pid) + return + } f.mu.Lock() lookahead := f.gloasLookahead f.mu.Unlock() @@ -419,12 +428,15 @@ Process: if lookahead != nil && f.gloasLookahead == lookahead { f.clearGloasScan() } - if lookahead == nil && shouldBanProcessPeer(pid, err) { - f.rpc.BanPeer(pid) + if lookahead == nil && shouldBanProcessPeer(pid, err) && f.banPeer != nil { + f.banPeer(pid) } return } - if len(processBlocks) > 0 && nextGloasLookahead != nil && highestSlotProcessed <= f.highestSlotProcessed { + attemptedNewSlot := slices.ContainsFunc(processBlocks, func(block *cltypes.SignedBeaconBlock) bool { + return block.Block.Slot > f.highestSlotProcessed + }) + if attemptedNewSlot && nextGloasLookahead != nil && highestSlotProcessed <= f.highestSlotProcessed { if lookahead != nil && f.gloasLookahead == lookahead { f.clearGloasScan() } @@ -518,6 +530,29 @@ func connectedGloasBlocks(blocks []*cltypes.SignedBeaconBlock) bool { return true } +const ( + banInvalidGloasResponse = "ban-peer" + disableHTTPInvalidGloasResponse = "disable-http" +) + +func invalidGloasResponseAction(pid string) string { + if pid == "http-fallback" { + return disableHTTPInvalidGloasResponse + } + return banInvalidGloasResponse +} + +func (f *ForwardBeaconDownloader) rejectInvalidGloasResponse(pid string) { + switch invalidGloasResponseAction(pid) { + case banInvalidGloasResponse: + if f.banPeer != nil { + f.banPeer(pid) + } + case disableHTTPInvalidGloasResponse: + f.httpPreferred.Store(false) + } +} + func capRequestCount(start, count uint64) uint64 { if count == 0 || count-1 <= math.MaxUint64-start { return count diff --git a/cl/phase1/network/beacon_downloader_test.go b/cl/phase1/network/beacon_downloader_test.go index 614dee36dc5..fad008a1029 100644 --- a/cl/phase1/network/beacon_downloader_test.go +++ b/cl/phase1/network/beacon_downloader_test.go @@ -579,6 +579,57 @@ func TestForwardBeaconDownloaderNoProgressRestartsOverlap(t *testing.T) { require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot()) } +func TestForwardBeaconDownloaderOverlapOnlyProgressRetainsLookahead(t *testing.T) { + const frontier = uint64(100) + first := makeGloasBlock(frontier-2, hash(0xa1), common.Hash{}) + second := makeGloasBlock(frontier-1, hash(0xa2), hash(0xb1)) + third := makeGloasBlock(frontier, hash(0xa3), hash(0xb2)) + lookahead := makeGloasBlock(frontier+1, hash(0xa4), hash(0xb3)) + linkBeaconBlocks(t, first, second, third, lookahead) + + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + if requestCount.Add(1) == 1 { + return []*cltypes.SignedBeaconBlock{first, second, third, lookahead}, "peer-a", nil + } + <-ctx.Done() + return nil, "peer-b", ctx.Err() + }, + } + downloader.SetHighestProcessedSlot(frontier) + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + for _, block := range blocks { + require.LessOrEqual(t, block.Block.Slot, highest) + } + return highest, nil + }) + + downloader.RequestMore(t.Context()) + firstRequest := <-requests + require.Equal(t, frontier-2, firstRequest.StartSlot) + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(ctx) + }() + next := <-requests + cancel() + <-done + require.Equal(t, frontier+31, next.StartSlot) +} + +func TestInvalidGloasResponseAction(t *testing.T) { + require.Equal(t, "ban-peer", invalidGloasResponseAction("peer-a")) + require.Equal(t, "disable-http", invalidGloasResponseAction("http-fallback")) +} + func TestForwardBeaconDownloaderRejectsDisconnectedGloasLookahead(t *testing.T) { const frontier = uint64(100) cached := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{}) @@ -587,8 +638,12 @@ func TestForwardBeaconDownloaderRejectsDisconnectedGloasLookahead(t *testing.T) requests := make(chan cltypes.BeaconBlocksByRangeRequest, 3) var requestCount atomic.Int32 + var banned atomic.Int32 downloader := &ForwardBeaconDownloader{ beaconCfg: &clparams.MainnetBeaconConfig, + banPeer: func(string) { + banned.Add(1) + }, requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} switch requestCount.Add(1) { @@ -617,6 +672,7 @@ func TestForwardBeaconDownloaderRejectsDisconnectedGloasLookahead(t *testing.T) downloader.RequestMore(t.Context()) <-requests require.Zero(t, processed.Load()) + require.Zero(t, banned.Load()) require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) ctx, cancel := context.WithCancel(t.Context()) @@ -643,8 +699,12 @@ func TestForwardBeaconDownloaderRejectsDisconnectedGloasResponseSuffix(t *testin requests := make(chan cltypes.BeaconBlocksByRangeRequest, 3) var requestCount atomic.Int32 + banned := make(chan string, 1) downloader := &ForwardBeaconDownloader{ beaconCfg: &clparams.MainnetBeaconConfig, + banPeer: func(pid string) { + banned <- pid + }, requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} switch requestCount.Add(1) { @@ -673,6 +733,7 @@ func TestForwardBeaconDownloaderRejectsDisconnectedGloasResponseSuffix(t *testin downloader.RequestMore(t.Context()) <-requests require.Zero(t, processed.Load()) + require.Equal(t, "peer-b", <-banned) require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) ctx, cancel := context.WithCancel(t.Context()) @@ -684,7 +745,60 @@ func TestForwardBeaconDownloaderRejectsDisconnectedGloasResponseSuffix(t *testin retry := <-requests cancel() <-done - require.Equal(t, frontier-2, retry.StartSlot) + require.Equal(t, frontier+31, retry.StartSlot) +} + +func TestForwardBeaconDownloaderDisconnectedHTTPResponseFallsBackToP2P(t *testing.T) { + const frontier = uint64(100) + first := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{}) + second := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1)) + second.Block.ParentRoot = hash(0xff) + firstEncoded, err := first.EncodeSSZ(nil) + require.NoError(t, err) + secondEncoded, err := second.EncodeSSZ(nil) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Eth-Consensus-Version", "gloas") + switch { + case strings.HasSuffix(r.URL.Path, "/beacon/blocks/101"): + _, _ = w.Write(firstEncoded) + case strings.HasSuffix(r.URL.Path, "/beacon/blocks/102"): + _, _ = w.Write(secondEncoded) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig) + downloader.SetHighestProcessedSlot(frontier) + downloader.SetHTTPFallbackURL(server.URL) + downloader.httpPreferred.Store(true) + p2pRequests := make(chan cltypes.BeaconBlocksByRangeRequest, 1) + downloader.requestBlocksByRange = func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + p2pRequests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + <-ctx.Done() + return nil, "peer-a", ctx.Err() + } + downloader.SetProcessFunction(func(highest uint64, _ []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + return highest, nil + }) + + downloader.RequestMore(t.Context()) + require.False(t, downloader.httpPreferred.Load()) + require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(ctx) + }() + request := <-p2pRequests + cancel() + <-done + require.Equal(t, frontier-2, request.StartSlot) } func TestForwardBeaconDownloaderOverlappingEmptyDoesNotSkipGloasLookahead(t *testing.T) { From 056d5bbb95b42f6dd3eb0422cfa9feca18e7c12a Mon Sep 17 00:00:00 2001 From: kewei Date: Thu, 27 Aug 2026 23:59:38 +0800 Subject: [PATCH 38/43] cl/builder: restrict strict-public IPv6 targets --- cl/beacon/builder/client.go | 5 ++++ cl/beacon/builder/client_test.go | 46 +++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/cl/beacon/builder/client.go b/cl/beacon/builder/client.go index 73fb8d03f48..fb16f84e500 100644 --- a/cl/beacon/builder/client.go +++ b/cl/beacon/builder/client.go @@ -431,6 +431,9 @@ func isPublicBuilderIP(ip net.IP) bool { return false } addr = addr.Unmap() + if addr.Is6() && !publicIPv6BuilderPrefix.Contains(addr) { + return false + } if !addr.IsGlobalUnicast() { return false } @@ -442,6 +445,8 @@ func isPublicBuilderIP(ip net.IP) bool { return true } +var publicIPv6BuilderPrefix = netip.MustParsePrefix("2000::/3") + var nonPublicBuilderPrefixes = []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("127.0.0.0/8"), diff --git a/cl/beacon/builder/client_test.go b/cl/beacon/builder/client_test.go index 7e015d5b020..079c3a68076 100644 --- a/cl/beacon/builder/client_test.go +++ b/cl/beacon/builder/client_test.go @@ -754,20 +754,46 @@ func TestSubmitSignedBeaconBlockPublicRejectsPrivateTargetWithPrivateConfigured( } func TestIsPublicBuilderIPRejectsSpecialPurposeRanges(t *testing.T) { - for _, value := range []string{ - "10.0.0.1", "100.64.0.1", "127.0.0.1", "169.254.1.1", "192.0.2.1", - "198.18.0.1", "198.51.100.1", "203.0.113.1", "240.0.0.1", - "::1", "64:ff9b::1", "64:ff9b:1::1", "100::1", "100:0:0:1::1", - "2001:db8::1", "fc00::1", "fec0::1", "fe80::1", - "::ffff:100.64.0.1", - } { - require.False(t, isPublicBuilderIP(net.ParseIP(value)), value) + tests := []struct { + address string + public bool + }{ + {address: "10.0.0.1"}, {address: "100.64.0.1"}, {address: "127.0.0.1"}, + {address: "169.254.1.1"}, {address: "192.0.2.1"}, {address: "198.18.0.1"}, + {address: "198.51.100.1"}, {address: "203.0.113.1"}, {address: "240.0.0.1"}, + {address: "::1"}, {address: "::2"}, {address: "::127.0.0.1"}, + {address: "::169.254.169.254"}, {address: "64:ff9b::1"}, {address: "64:ff9b:1::1"}, + {address: "100::1"}, {address: "100:0:0:1::1"}, {address: "2001:db8::1"}, + {address: "1fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"}, + {address: "2000::", public: true}, + {address: "3fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", public: true}, + {address: "4000::"}, + {address: "4000::1"}, {address: "6000::1"}, {address: "fc00::1"}, + {address: "fec0::1"}, {address: "fe80::1"}, {address: "::ffff:100.64.0.1"}, + {address: "::ffff:127.0.0.1"}, + {address: "8.8.8.8", public: true}, {address: "93.184.216.34", public: true}, + {address: "::ffff:8.8.8.8", public: true}, {address: "2001:4860:4860::8888", public: true}, } - for _, value := range []string{"8.8.8.8", "93.184.216.34", "2001:4860:4860::8888"} { - require.True(t, isPublicBuilderIP(net.ParseIP(value)), value) + for _, test := range tests { + t.Run(test.address, func(t *testing.T) { + require.Equal(t, test.public, isPublicBuilderIP(net.ParseIP(test.address))) + }) } } +func TestSubmitSignedBeaconBlockPublicRejectsIPv4CompatibleLiteralBeforeDial(t *testing.T) { + client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{AllowPrivate: true}) + dialed := false + client.publicTransport = mockRoundTripper(func(r *http.Request) (*http.Response, error) { + dialed = true + return builderTestResponse(r, http.StatusAccepted, "", nil), nil + }) + + err := client.SubmitSignedBeaconBlockPublic(t.Context(), "http://[::127.0.0.1]:18550", cltypes.NewSignedBeaconBlock(mockBeaconConfig, clparams.GloasVersion)) + require.ErrorContains(t, err, "disallowed address") + require.False(t, dialed) +} + func TestSubmitSignedBeaconBlockPublicRejectsNewSpecialPurposeMixedAnswers(t *testing.T) { for _, special := range []string{"fec0::1", "64:ff9b::1", "100:0:0:1::1"} { t.Run(special, func(t *testing.T) { From 20d9fe857f06fdfdba734e47e0890a586d6060fe Mon Sep 17 00:00:00 2001 From: kewei Date: Fri, 28 Aug 2026 01:31:13 +0800 Subject: [PATCH 39/43] cl: address Gloas publish review findings --- cl/beacon/handler/block_production.go | 33 +++- cl/beacon/handler/block_production_test.go | 168 +++++++++++++++++- cl/beacon/handler/epbs.go | 10 +- cl/beacon/handler/epbs_test.go | 92 ++++++++++ cl/phase1/forkchoice/forkchoice_test.go | 18 +- .../mock_services/forkchoice_mock.go | 4 +- cl/phase1/forkchoice/on_block.go | 6 +- cl/phase1/network/beacon_downloader.go | 27 +++ cl/phase1/network/beacon_downloader_test.go | 60 ++++++- cl/phase1/stages/forward_sync.go | 1 + 10 files changed, 406 insertions(+), 13 deletions(-) diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 5b96597620f..5c0cc78d231 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -2226,6 +2226,10 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac if err != nil { return err } + blockRoot, err := blk.Block.HashSSZ() + if err != nil { + return err + } blkCommitments := blk.Block.Body.GetBlobKzgCommitments() blkCommitmentsLen := 0 if blkCommitments != nil { @@ -2322,7 +2326,7 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac } store := func(ctx context.Context) error { - return a.storeBlockAndBlobs(ctx, blk, blobsSidecars, columnsSidecars, validation == BlockPublishingValidationConsensusAndEquivocation) + return a.storeBlockAndBlobs(ctx, blk, blobsSidecars, columnsSidecars, validation) } if validation != BlockPublishingValidationGossip { if err := store(ctx); err != nil { @@ -2344,6 +2348,10 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac "blobs", lenBlobs, ) + if validation == BlockPublishingValidationConsensusAndEquivocation && + a.forkchoiceStore.HasBlockEquivocation(blk.Block.Slot, blk.Block.ProposerIndex, blockRoot) { + return fmt.Errorf("%w: block conflicts with a previously validated proposal", errPublishedBlockValidation) + } // Broadcast the block and its blobs if err := a.publishGossip(ctx, gossip.TopicNameBeaconBlock, blkSSZ); err != nil { return err @@ -2451,7 +2459,7 @@ func (a *ApiHandler) storeBlockAndBlobs( block *cltypes.SignedBeaconBlock, sidecars []*cltypes.BlobSidecar, columnSidecars []*cltypes.DataColumnSidecar, - rejectEquivocation bool, + validation BlockPublishingValidation, ) error { finishProduction := a.payloadPreparationGate.beginProduction() defer finishProduction() @@ -2460,6 +2468,20 @@ func (a *ApiHandler) storeBlockAndBlobs( if err != nil { return err } + knownBlock := false + rejectEquivocation := validation == BlockPublishingValidationConsensusAndEquivocation + if _, exists := a.forkchoiceStore.GetHeader(blockRoot); exists { + stored, ok := a.forkchoiceStore.GetBlock(blockRoot) + if !ok || !rootKeyedSignedBlockReplayMatches(stored, block) { + return fmt.Errorf("%w: published block does not match the validated block for its root", errPublishedBlockValidation) + } + knownBlock = true + if rejectEquivocation && a.forkchoiceStore.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, blockRoot) { + return fmt.Errorf("%w: block conflicts with a previously validated proposal", errPublishedBlockValidation) + } + } else if validation != BlockPublishingValidationGossip && block.Block.Slot <= a.forkchoiceStore.FinalizedSlot() { + return fmt.Errorf("%w: block is at or below the finalized validation horizon", errPublishedBlockValidation) + } if err := a.storeDataColumnSidecars(ctx, blockRoot, columnSidecars); err != nil { return fmt.Errorf("%w: %w", errPublishedBlockDataStorage, err) } @@ -2471,7 +2493,7 @@ func (a *ApiHandler) storeBlockAndBlobs( } currentSlot := a.ethClock.GetCurrentSlot() a.forkchoiceStore.OnTick(a.ethClock.GenesisTime() + currentSlot*a.beaconChainCfg.SecondsPerSlot) - if _, exists := a.forkchoiceStore.GetHeader(blockRoot); !exists { + if !knownBlock { var blockErr error if rejectEquivocation { blockErr = a.forkchoiceStore.OnBlockWithEquivocationCheck(ctx, block, true, true, false) @@ -2525,6 +2547,11 @@ func (a *ApiHandler) storeBlockAndBlobs( return nil } +func rootKeyedSignedBlockReplayMatches(stored, incoming *cltypes.SignedBeaconBlock) bool { + return stored != nil && incoming != nil && stored.Block != nil && incoming.Block != nil && + stored.Version() == incoming.Version() && stored.Signature == incoming.Signature +} + func (a *ApiHandler) storeDataColumnSidecars(ctx context.Context, blockRoot common.Hash, sidecars []*cltypes.DataColumnSidecar) error { if len(sidecars) == 0 { return nil diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index 0f211b8fc07..3647b0953a7 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -52,6 +52,7 @@ import ( network_services_mock "github.com/erigontech/erigon/cl/phase1/network/services/mock_services" serviceinterface "github.com/erigontech/erigon/cl/phase1/network/services/service_interface" "github.com/erigontech/erigon/cl/pool" + "github.com/erigontech/erigon/cl/validator/attestation_producer" sync_pool_mock "github.com/erigontech/erigon/cl/validator/sync_contribution_pool/mock_services" "github.com/erigontech/erigon/cl/validator/validator_params" "github.com/erigontech/erigon/common" @@ -166,20 +167,41 @@ func (db unavailableUpdateDB) Update(context.Context, func(kv.RwTx) error) error type installingForkchoice struct { forkchoice.ForkChoiceStorage headers map[common.Hash]*cltypes.BeaconBlockHeader + blocks map[common.Hash]*cltypes.SignedBeaconBlock onBlockCalls int } +type conflictAfterValidationForkchoice struct { + forkchoice.ForkChoiceStorage + conflict bool +} + +func (f *conflictAfterValidationForkchoice) OnBlockWithEquivocationCheck(context.Context, *cltypes.SignedBeaconBlock, bool, bool, bool) error { + f.conflict = true + return nil +} + +func (f *conflictAfterValidationForkchoice) HasBlockEquivocation(uint64, uint64, common.Hash) bool { + return f.conflict +} + func (f *installingForkchoice) GetHeader(root common.Hash) (*cltypes.BeaconBlockHeader, bool) { header, ok := f.headers[root] return header, ok } +func (f *installingForkchoice) GetBlock(root common.Hash) (*cltypes.SignedBeaconBlock, bool) { + block, ok := f.blocks[root] + return block, ok +} + func (f *installingForkchoice) OnBlock(_ context.Context, block *cltypes.SignedBeaconBlock, _, _, _ bool) error { root, err := block.Block.HashSSZ() if err != nil { return err } f.headers[root] = block.SignedBeaconBlockHeader().Header.Copy() + f.blocks[root] = block f.onBlockCalls++ return nil } @@ -1007,6 +1029,149 @@ func TestBroadcastBlockRunsGossipValidationBeforePublishing(t *testing.T) { require.ErrorContains(t, err, validationErr.Error()) } +func TestBroadcastBlockRejectsKnownRootWithDifferentSignature(t *testing.T) { + for _, version := range []clparams.StateVersion{clparams.Phase0Version, clparams.GloasVersion} { + for _, validation := range []BlockPublishingValidation{BlockPublishingValidationConsensus, BlockPublishingValidationConsensusAndEquivocation} { + t.Run(version.String()+"/"+string(validation), func(t *testing.T) { + _, blocks, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + stored := blocks[1] + if version == clparams.GloasVersion { + stored = cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, version) + stored.Block.Slot = blocks[1].Block.Slot + } + root, err := stored.Block.HashSSZ() + require.NoError(t, err) + fcu.Headers[root] = stored.SignedBeaconBlockHeader().Header + fcu.Blocks[root] = stored + fcu.OnTickFn = func(uint64) {} + + incoming := &cltypes.SignedBeaconBlock{Block: stored.Block, Signature: stored.Signature} + incoming.Signature[0] ^= 1 + incomingRoot, rootErr := incoming.Block.HashSSZ() + require.NoError(t, rootErr) + require.Equal(t, root, incomingRoot) + handler.gossipManager = gossip_mock.NewMockGossip(gomock.NewController(t)) + handler.indiciesDB = unavailableUpdateDB{RwDB: handler.indiciesDB} + + err = handler.broadcastBlock(t.Context(), incoming, validation) + require.ErrorIs(t, err, errPublishedBlockValidation) + require.Same(t, stored, fcu.Blocks[root]) + }) + } + } +} + +func TestBroadcastBlockAcceptsExactKnownReplay(t *testing.T) { + for _, version := range []clparams.StateVersion{clparams.Phase0Version, clparams.GloasVersion} { + t.Run(version.String(), func(t *testing.T) { + _, blocks, _, _, postState, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + block := blocks[1] + if version == clparams.GloasVersion { + block = cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, version) + block.Block.Slot = blocks[1].Block.Slot + } + root, err := block.Block.HashSSZ() + require.NoError(t, err) + fcu.Headers[root] = block.SignedBeaconBlockHeader().Header + fcu.Blocks[root] = block + fcu.StateAtBlockRootVal[root] = postState + fcu.HeadVal = root + fcu.HeadSlotVal = block.Block.Slot + fcu.OnTickFn = func(uint64) {} + ctrl := gomock.NewController(t) + engine := execution_client.NewMockExecutionEngine(ctrl) + engine.EXPECT().ForkChoiceUpdate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).Times(2) + handler.engine = engine + handler.attestationProducer = attestation_producer.New(t.Context(), handler.beaconChainCfg) + gossipManager := gossip_mock.NewMockGossip(ctrl) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, gomock.Any()).Return(nil).Times(2) + handler.gossipManager = gossipManager + + incoming := &cltypes.SignedBeaconBlock{Block: block.Block, Signature: block.Signature} + incomingRoot, err := incoming.Block.HashSSZ() + require.NoError(t, err) + require.Equal(t, root, incomingRoot) + _, known := fcu.GetHeader(incomingRoot) + require.True(t, known) + for _, validation := range []BlockPublishingValidation{BlockPublishingValidationConsensus, BlockPublishingValidationConsensusAndEquivocation} { + require.NoError(t, handler.broadcastBlock(t.Context(), incoming, validation)) + } + }) + } +} + +func TestBroadcastBlockRejectsUnknownBlockAtFinalizedHorizonBeforeWrites(t *testing.T) { + for _, version := range []clparams.StateVersion{clparams.Phase0Version, clparams.GloasVersion} { + for _, validation := range []BlockPublishingValidation{BlockPublishingValidationConsensus, BlockPublishingValidationConsensusAndEquivocation} { + t.Run(version.String()+"/"+string(validation), func(t *testing.T) { + ctrl := gomock.NewController(t) + _, blocks, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + block := blocks[1] + if version == clparams.GloasVersion { + block = cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, version) + block.Block.Slot = blocks[1].Block.Slot + } + fcu.FinalizedSlotVal = block.Block.Slot + handler.blobStoage = blob_storage_mock.NewMockBlobStorage(ctrl) + handler.gossipManager = gossip_mock.NewMockGossip(ctrl) + handler.indiciesDB = unavailableUpdateDB{RwDB: handler.indiciesDB} + + err := handler.broadcastBlock(t.Context(), block, validation) + require.ErrorIs(t, err, errPublishedBlockValidation) + require.ErrorContains(t, err, "finalized validation horizon") + }) + } + } +} + +func TestBroadcastBlockRechecksEquivocationAfterStoreBeforePublish(t *testing.T) { + ctrl := gomock.NewController(t) + _, blocks, _, _, postState, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + block := blocks[1] + root, err := block.Block.HashSSZ() + require.NoError(t, err) + fcu.StateAtBlockRootVal[root] = postState + fcu.HeadVal = root + fcu.HeadSlotVal = block.Block.Slot + fcu.OnTickFn = func(uint64) {} + handler.forkchoiceStore = &conflictAfterValidationForkchoice{ForkChoiceStorage: fcu} + engine := execution_client.NewMockExecutionEngine(ctrl) + engine.EXPECT().ForkChoiceUpdate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil) + handler.engine = engine + handler.attestationProducer = attestation_producer.New(t.Context(), handler.beaconChainCfg) + handler.gossipManager = gossip_mock.NewMockGossip(ctrl) + + err = handler.broadcastBlock(t.Context(), block, BlockPublishingValidationConsensusAndEquivocation) + + require.ErrorIs(t, err, errPublishedBlockValidation) + require.ErrorContains(t, err, "conflicts with a previously validated proposal") +} + +func TestBroadcastBlockRejectsKnownBlockAfterConflictingProposal(t *testing.T) { + ctrl := gomock.NewController(t) + _, blocks, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + block.Block.Slot = blocks[1].Block.Slot + block.Block.ProposerIndex = 7 + root, err := block.Block.HashSSZ() + require.NoError(t, err) + conflict := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + conflict.Block.Slot = block.Block.Slot + conflict.Block.ProposerIndex = block.Block.ProposerIndex + conflict.Block.StateRoot[0] = 1 + fcu.Headers[root] = block.SignedBeaconBlockHeader().Header + fcu.Headers[common.Hash{0xff}] = conflict.SignedBeaconBlockHeader().Header + fcu.Blocks[root] = block + fcu.Blocks[common.Hash{0xff}] = conflict + handler.indiciesDB = unavailableUpdateDB{RwDB: handler.indiciesDB} + handler.gossipManager = gossip_mock.NewMockGossip(ctrl) + + err = handler.broadcastBlock(t.Context(), block, BlockPublishingValidationConsensusAndEquivocation) + + require.ErrorIs(t, err, errPublishedBlockValidation) + require.ErrorContains(t, err, "conflicts with a previously validated proposal") +} + func TestBroadcastBlockReleasesGossipReservationAfterPreparationFailure(t *testing.T) { ctrl := gomock.NewController(t) blockService := network_services_mock.NewMockBlockService(ctrl) @@ -1140,12 +1305,13 @@ func TestStoreBlockAndBlobsDoesNotRepeatForkchoiceAfterDatabaseFailure(t *testin installing := &installingForkchoice{ ForkChoiceStorage: fcu, headers: make(map[common.Hash]*cltypes.BeaconBlockHeader), + blocks: make(map[common.Hash]*cltypes.SignedBeaconBlock), } handler.forkchoiceStore = installing handler.indiciesDB = unavailableUpdateDB{RwDB: handler.indiciesDB} for range 2 { - err := handler.storeBlockAndBlobs(t.Context(), block, nil, nil, false) + err := handler.storeBlockAndBlobs(t.Context(), block, nil, nil, BlockPublishingValidationGossip) require.ErrorContains(t, err, "database unavailable") } require.Equal(t, 1, installing.onBlockCalls) diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index 6e74a3bb378..1290cc9e5a2 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -1022,7 +1022,7 @@ func (a *ApiHandler) decodeExecutionPayloadEnvelopeRequest(w http.ResponseWriter signedEnvelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(a.beaconChainCfg)} var contents *cltypes.SignedExecutionPayloadEnvelopeContents if blobDataIncluded { - contents = cltypes.NewSignedExecutionPayloadEnvelopeContents(a.beaconChainCfg, a.ethClock.GetCurrentSlot()) + contents = newSignedExecutionPayloadEnvelopeContentsForDecoding(a.beaconChainCfg, a.ethClock.GetCurrentSlot()) } switch contentType { case "application/json": @@ -1058,6 +1058,14 @@ func (a *ApiHandler) decodeExecutionPayloadEnvelopeRequest(w http.ResponseWriter return signedEnvelope, contents, nil } +func newSignedExecutionPayloadEnvelopeContentsForDecoding(cfg *clparams.BeaconChainConfig, currentSlot uint64) *cltypes.SignedExecutionPayloadEnvelopeContents { + contents := cltypes.NewSignedExecutionPayloadEnvelopeContents(cfg, currentSlot) + maxBlobs := int(min(cfg.MaxBlobsPerBlockUpperBound(), uint64(cltypes.MaxBlobsCommittmentsPerBlock))) + contents.KZGProofs = solid.NewStaticListSSZ[*cltypes.KZGProof](maxBlobs*int(cfg.NumberOfColumns), cltypes.BYTES_KZG_PROOF) + contents.Blobs = solid.NewStaticListSSZ[*cltypes.Blob](maxBlobs, int(cltypes.BYTES_PER_BLOB)) + return contents +} + func (a *ApiHandler) validateAndStoreExecutionPayloadEnvelopeContents(ctx context.Context, contents *cltypes.SignedExecutionPayloadEnvelopeContents) error { if contents == nil || contents.SignedExecutionPayloadEnvelope == nil { return errors.New("execution payload envelope contents has nil envelope") diff --git a/cl/beacon/handler/epbs_test.go b/cl/beacon/handler/epbs_test.go index a62bf2145b6..7726c9594db 100644 --- a/cl/beacon/handler/epbs_test.go +++ b/cl/beacon/handler/epbs_test.go @@ -544,6 +544,94 @@ func TestPostExecutionPayloadEnvelopesRejectsMalformedContents(t *testing.T) { require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) } +func TestPostExecutionPayloadEnvelopesSSZDecodesReferencedScheduleCapacity(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + currentSlot := handler.ethClock.GetCurrentSlot() + referencedSlot := currentSlot - 1 + handler.beaconChainCfg.SlotsPerEpoch = 1 + handler.beaconChainCfg.NumberOfColumns = 1 + handler.beaconChainCfg.MaxBlobsPerBlock = 1 + handler.beaconChainCfg.MaxBlobsPerBlockElectra = 1 + handler.beaconChainCfg.BlobSchedule = []clparams.BlobParameters{ + {Epoch: referencedSlot, MaxBlobsPerBlock: 2}, + {Epoch: currentSlot, MaxBlobsPerBlock: 1}, + } + + contents := cltypes.NewSignedExecutionPayloadEnvelopeContents(handler.beaconChainCfg, referencedSlot) + root := common.Hash{0x42} + contents.SignedExecutionPayloadEnvelope.Message.BeaconBlockRoot = root + for i := byte(1); i <= 2; i++ { + contents.Blobs.Append(&cltypes.Blob{i}) + contents.KZGProofs.Append(&cltypes.KZGProof{i}) + } + body, err := contents.EncodeSSZ(nil) + require.NoError(t, err) + + validationErr := errors.New("decoded contents reached validation") + fcu.ValidateExecutionPayloadEnvelopeErr = validationErr + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("Eth-Blob-Data-Included", "true") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), validationErr.Error()) +} + +func TestPostExecutionPayloadEnvelopesUsesReferencedBlockForExactBlobCount(t *testing.T) { + for _, contentType := range []string{"application/json", "application/octet-stream"} { + t.Run(contentType, func(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + currentSlot := handler.ethClock.GetCurrentSlot() + referencedSlot := currentSlot - 1 + handler.beaconChainCfg.SlotsPerEpoch = 1 + handler.beaconChainCfg.NumberOfColumns = 1 + handler.beaconChainCfg.MaxBlobsPerBlock = 1 + handler.beaconChainCfg.MaxBlobsPerBlockElectra = 1 + handler.beaconChainCfg.BlobSchedule = []clparams.BlobParameters{ + {Epoch: referencedSlot, MaxBlobsPerBlock: 1}, + {Epoch: currentSlot, MaxBlobsPerBlock: 2}, + } + + root := common.Hash{0x43} + block := &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{ + Slot: referencedSlot, + Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion), + }} + block.Block.Body.GetSignedExecutionPayloadBid().Message.BlobKzgCommitments.Append(&cltypes.KZGCommitment{1}) + fcu.Blocks[root] = block + + contents := cltypes.NewSignedExecutionPayloadEnvelopeContents(handler.beaconChainCfg, currentSlot) + contents.SignedExecutionPayloadEnvelope.Message.BeaconBlockRoot = root + for i := byte(1); i <= 2; i++ { + contents.Blobs.Append(&cltypes.Blob{i}) + contents.KZGProofs.Append(&cltypes.KZGProof{i}) + } + var body []byte + var err error + if contentType == "application/json" { + body, err = json.Marshal(contents) + } else { + body, err = contents.EncodeSSZ(nil) + } + require.NoError(t, err) + + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", bytes.NewReader(body)) + request.Header.Set("Content-Type", contentType) + request.Header.Set("Eth-Blob-Data-Included", "true") + request.Header.Set("Eth-Consensus-Version", "gloas") + recorder := httptest.NewRecorder() + handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), "counts do not match") + }) + } +} + func TestPostExecutionPayloadEnvelopesRejectsTrailingJSON(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} @@ -653,6 +741,10 @@ func TestPostExecutionPayloadEnvelopesRejectsEquivocatingBlockBeforeGossip(t *te root: {Block: &cltypes.BeaconBlock{Slot: 12, ProposerIndex: 3, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}, {2}: {Block: &cltypes.BeaconBlock{Slot: 12, ProposerIndex: 3, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}, } + fcu.Headers = map[common.Hash]*cltypes.BeaconBlockHeader{ + root: {Slot: 12, ProposerIndex: 3}, + {2}: {Slot: 12, ProposerIndex: 3}, + } envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} envelope.Message.BeaconBlockRoot = root body, err := json.Marshal(envelope) diff --git a/cl/phase1/forkchoice/forkchoice_test.go b/cl/phase1/forkchoice/forkchoice_test.go index eb39df5a9ff..1daa68ea0a6 100644 --- a/cl/phase1/forkchoice/forkchoice_test.go +++ b/cl/phase1/forkchoice/forkchoice_test.go @@ -591,10 +591,26 @@ type getFinalizedExecutionHashForkGraph struct { anchorRoot common.Hash anchorSlot uint64 currentJustified solid.Checkpoint + hasBlockEquivocation bool } func (f *getFinalizedExecutionHashForkGraph) HasBlockEquivocation(uint64, uint64, common.Hash) bool { - return false + return f.hasBlockEquivocation +} + +func TestOnBlockWithEquivocationCheckRejectsKnownGloasConflict(t *testing.T) { + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + graph := &getFinalizedExecutionHashForkGraph{ + headers: map[common.Hash]*cltypes.BeaconBlockHeader{root: block.SignedBeaconBlockHeader().Header}, + hasBlockEquivocation: true, + } + store := &ForkChoiceStore{forkGraph: graph} + + err = store.OnBlockWithEquivocationCheck(t.Context(), block, true, true, false) + require.ErrorContains(t, err, "conflicts with a previously validated proposal") + require.NoError(t, store.OnBlock(t.Context(), block, true, true, false)) } func (g *getFinalizedExecutionHashForkGraph) AddChainSegment(*cltypes.SignedBeaconBlock, bool) (*state.CachingBeaconState, fork_graph.ChainSegmentInsertionResult, error) { diff --git a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go index 10e21f166a7..beec2e23672 100644 --- a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go +++ b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go @@ -451,8 +451,8 @@ func (f *ForkChoiceStorageMock) HasBlockChildAtOrAfter(blockRoot common.Hash, sl } func (f *ForkChoiceStorageMock) HasBlockEquivocation(slot, proposerIndex uint64, exceptRoot common.Hash) bool { - for root, block := range f.Blocks { - if root != exceptRoot && block != nil && block.Block != nil && block.Block.Slot == slot && block.Block.ProposerIndex == proposerIndex { + for root, header := range f.Headers { + if root != exceptRoot && header != nil && header.Slot == slot && header.ProposerIndex == proposerIndex { return true } } diff --git a/cl/phase1/forkchoice/on_block.go b/cl/phase1/forkchoice/on_block.go index 207d169488a..d07293dd7ca 100644 --- a/cl/phase1/forkchoice/on_block.go +++ b/cl/phase1/forkchoice/on_block.go @@ -108,14 +108,14 @@ func (f *ForkChoiceStore) onBlock(ctx context.Context, block *cltypes.SignedBeac if err != nil { return err } + if rejectEquivocation && f.forkGraph.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, blockRoot) { + return errors.New("block conflicts with a previously validated proposal") + } if block.Version() >= clparams.GloasVersion { if _, ok := f.forkGraph.GetHeader(blockRoot); ok { return nil } } - if rejectEquivocation && f.forkGraph.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, blockRoot) { - return errors.New("block conflicts with a previously validated proposal") - } f.headHash = common.Hash{} f.headPayloadStatus = cltypes.PayloadStatusPending // Use the store's current slot (set via OnTick) to validate the block is not from the future. diff --git a/cl/phase1/network/beacon_downloader.go b/cl/phase1/network/beacon_downloader.go index 48fb6839eee..9860617357b 100644 --- a/cl/phase1/network/beacon_downloader.go +++ b/cl/phase1/network/beacon_downloader.go @@ -59,6 +59,7 @@ type ForwardBeaconDownloader struct { requestBlocksByRange func(context.Context, uint64, uint64) ([]*cltypes.SignedBeaconBlock, string, error) banPeer func(string) process ProcessFn + currentSlot func() uint64 beaconCfg *clparams.BeaconChainConfig httpFallbackURL string // beacon API base URL for HTTP fallback when P2P fails httpPreferred atomic.Bool // set after first HTTP fallback success; skips P2P probing @@ -68,6 +69,11 @@ type ForwardBeaconDownloader struct { gloasNextUnscanned uint64 } +// SetCurrentSlotSampler limits range requests to slots that can already exist. +func (f *ForwardBeaconDownloader) SetCurrentSlotSampler(currentSlot func() uint64) { + f.currentSlot = currentSlot +} + func NewForwardBeaconDownloader(ctx context.Context, rpc *rpc.BeaconRpcP2P, beaconCfg *clparams.BeaconChainConfig) *ForwardBeaconDownloader { f := &ForwardBeaconDownloader{ ctx: ctx, @@ -138,6 +144,10 @@ type peerAndBlocks struct { func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { count := uint64(32) + requestStart, _ := f.nextRequestStart(true) + if f.capAtCurrentSlot(requestStart, 1) == 0 { + return + } var atomicResp atomic.Value atomicResp.Store(peerAndBlocks{}) @@ -145,6 +155,7 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { if f.httpPreferred.Load() && f.httpFallbackURL != "" { httpStart, hadGloasPending := f.nextRequestStart(false) httpCount := capRequestCount(httpStart, count+10) + httpCount = f.capAtCurrentSlot(httpStart, httpCount) httpBlocks, httpErr := fetchBlocksFromBeaconAPI(ctx, f.httpFallbackURL, httpStart, httpCount, f.beaconCfg) switch { case httpErr == nil && len(httpBlocks) > 0: @@ -233,6 +244,7 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { if f.beaconCfg != nil { reqSlot, reqCount = f.capAtForkBoundary(reqSlot, reqCount) } + reqCount = f.capAtCurrentSlot(reqSlot, reqCount) // leave a warning if we are stuck for more than 90 seconds if time.Since(f.highestSlotUpdateTime) > 90*time.Second { @@ -261,6 +273,7 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { } httpStart, httpHadGloasPending := f.nextRequestStart(false) httpCount := capRequestCount(httpStart, count+10) + httpCount = f.capAtCurrentSlot(httpStart, httpCount) httpBlocks, httpErr := fetchBlocksFromBeaconAPI(ctx, f.httpFallbackURL, httpStart, httpCount, f.beaconCfg) if httpErr == nil && len(httpBlocks) > 0 { log.Debug("[ForwardBeaconDownloader] P2P failed, fetched blocks from beacon API", @@ -463,6 +476,20 @@ Process: } } +func (f *ForwardBeaconDownloader) capAtCurrentSlot(start, count uint64) uint64 { + if count == 0 || f.currentSlot == nil { + return count + } + currentSlot := f.currentSlot() + if start > currentSlot { + return 0 + } + if count-1 > currentSlot-start { + return currentSlot - start + 1 + } + return count +} + func (f *ForwardBeaconDownloader) nextRequestStart(overlap bool) (uint64, bool) { f.mu.Lock() defer f.mu.Unlock() diff --git a/cl/phase1/network/beacon_downloader_test.go b/cl/phase1/network/beacon_downloader_test.go index fad008a1029..4b23031b2ca 100644 --- a/cl/phase1/network/beacon_downloader_test.go +++ b/cl/phase1/network/beacon_downloader_test.go @@ -205,6 +205,7 @@ func TestForwardBeaconDownloaderScansPastGloasLookaheadGap(t *testing.T) { } }, } + downloader.SetCurrentSlotSampler(func() uint64 { return frontier + 66 }) downloader.SetHighestProcessedSlot(frontier) var processedSlots []uint64 downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { @@ -229,7 +230,7 @@ func TestForwardBeaconDownloaderScansPastGloasLookaheadGap(t *testing.T) { emptyRequest := <-requests require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier + 31, Count: 33}, emptyRequest) farRequest := <-requests - require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier + 64, Count: 33}, farRequest) + require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier + 64, Count: 3}, farRequest) require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) close(allowSuccessor) <-done @@ -237,6 +238,58 @@ func TestForwardBeaconDownloaderScansPastGloasLookaheadGap(t *testing.T) { require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot()) } +func TestForwardBeaconDownloaderRechecksNewSlotAfterNearHeadLookahead(t *testing.T) { + const frontier = uint64(100) + first := makeGloasBlock(frontier+1, hash(0xaa), common.Hash{}) + successor := makeGloasBlock(frontier+2, hash(0xbb), hash(0xcc)) + linkBeaconBlocks(t, first, successor) + + var currentSlot atomic.Uint64 + currentSlot.Store(frontier + 1) + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2) + var requestCount atomic.Int32 + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(_ context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + switch requestCount.Add(1) { + case 1: + return []*cltypes.SignedBeaconBlock{first}, "block-peer", nil + case 2: + return []*cltypes.SignedBeaconBlock{successor}, "block-peer", nil + default: + return nil, "block-peer", errors.New("unexpected request") + } + }, + } + downloader.SetCurrentSlotSampler(currentSlot.Load) + downloader.SetHighestProcessedSlot(frontier) + var processedSlots []uint64 + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + for _, block := range blocks { + processedSlots = append(processedSlots, block.Block.Slot) + highest = block.Block.Slot + } + return highest, nil + }) + + downloader.RequestMore(t.Context()) + require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier - 2, Count: 4}, <-requests) + require.Empty(t, processedSlots) + downloader.RequestMore(t.Context()) + select { + case request := <-requests: + t.Fatalf("requested future range before the slot existed: %+v", request) + default: + } + + currentSlot.Store(frontier + 2) + downloader.RequestMore(t.Context()) + require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier + 2, Count: 1}, <-requests) + require.Equal(t, []uint64{frontier + 1}, processedSlots) + require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot()) +} + func TestForwardBeaconDownloaderHTTPScansPastGloasLookaheadGap(t *testing.T) { const frontier = uint64(100) first := makeGloasBlock(frontier+1, hash(0xaa), common.Hash{}) @@ -265,6 +318,7 @@ func TestForwardBeaconDownloaderHTTPScansPastGloasLookaheadGap(t *testing.T) { defer server.Close() downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig) + downloader.SetCurrentSlotSampler(func() uint64 { return frontier + 45 }) downloader.SetHighestProcessedSlot(frontier) downloader.SetHTTPFallbackURL(server.URL) downloader.httpPreferred.Store(true) @@ -288,11 +342,12 @@ func TestForwardBeaconDownloaderHTTPScansPastGloasLookaheadGap(t *testing.T) { require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) downloader.RequestMore(t.Context()) - secondRange := make([]uint64, 42) + secondRange := make([]uint64, 3) for i := range secondRange { secondRange[i] = <-requestedSlots } require.Contains(t, secondRange, frontier+43) + require.Contains(t, secondRange, frontier+45) require.NotContains(t, secondRange, frontier+1) require.Equal(t, []uint64{frontier + 1}, processedSlots) require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot()) @@ -376,6 +431,7 @@ func TestForwardBeaconDownloaderPendingGloasScanDoesNotWrapAtMaxSlot(t *testing. return nil, "block-peer", ctx.Err() }, } + downloader.SetCurrentSlotSampler(func() uint64 { return math.MaxUint64 }) downloader.SetHighestProcessedSlot(frontier) downloader.SetProcessFunction(func(highest uint64, _ []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { return highest, nil diff --git a/cl/phase1/stages/forward_sync.go b/cl/phase1/stages/forward_sync.go index 9c20529d662..3d160ce8de2 100644 --- a/cl/phase1/stages/forward_sync.go +++ b/cl/phase1/stages/forward_sync.go @@ -259,6 +259,7 @@ func forwardSync(ctx context.Context, logger log.Logger, cfg *Cfg, args Args) er // Always start from the current finalized checkpoint downloader.SetHighestProcessedSlot(currentSlot.Load()) downloader.SetMinSlot(startSlot) + downloader.SetCurrentSlotSampler(cfg.ethClock.GetCurrentSlot) // Set the function to process downloaded blocks downloader.SetProcessFunction(func(initialHighestSlotProcessed uint64, blocks []*cltypes.SignedBeaconBlock, envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (newHighestSlotProcessed uint64, err error) { From 30cc0b00662bd0ebd376d760005d1657d59aad6d Mon Sep 17 00:00:00 2001 From: kewei Date: Fri, 28 Aug 2026 03:13:02 +0800 Subject: [PATCH 40/43] cl: harden Gloas publication and fallback sync --- cl/beacon/handler/block_production.go | 62 ++-- cl/beacon/handler/block_production_test.go | 265 +++++++++++++++++- cl/beacon/handler/epbs.go | 3 + cl/beacon/handler/epbs_test.go | 13 + cl/beacon/handler/utils_test.go | 6 +- cl/phase1/forkchoice/fork_choice_test.go | 8 + cl/phase1/forkchoice/interface.go | 1 + .../mock_services/forkchoice_mock.go | 24 ++ cl/phase1/forkchoice/on_block.go | 160 ++++++++--- .../on_block_fork_consistency_test.go | 8 + .../network/backward_beacon_downloader.go | 51 +++- .../backward_beacon_downloader_test.go | 40 +++ cl/phase1/network/beacon_downloader.go | 164 +++++++---- cl/phase1/network/beacon_downloader_test.go | 252 ++++++++++++++++- cl/phase1/network/services/block_service.go | 174 ++++++++++-- .../network/services/block_service_test.go | 198 ++++++++++++- .../services/execution_payload_bid_service.go | 12 +- .../execution_payload_bid_service_test.go | 16 +- .../services/execution_payload_service.go | 18 +- .../execution_payload_service_test.go | 13 + .../mock_services/block_service_mock.go | 15 +- cl/phase1/network/services/types.go | 6 +- 22 files changed, 1302 insertions(+), 207 deletions(-) diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go index 5c0cc78d231..a371c121720 100644 --- a/cl/beacon/handler/block_production.go +++ b/cl/beacon/handler/block_production.go @@ -83,6 +83,7 @@ const ( var errBuilderNotEnabled = errors.New("builder is not enabled") var errPublishedBlockValidation = errors.New("published block validation failed") var errPublishedBlockDataStorage = errors.New("published block data storage failed") +var errPublishedBlockAccepted = errors.New("published block broadcast before integration rejection") const ( caplinClientCode = "CN" @@ -917,12 +918,6 @@ func (a *ApiHandler) produceBlock( Cfg: a.beaconChainCfg, } if !a.routerCfg.Builder || builderErr != nil || stateVersion.AfterOrEqual(clparams.GloasVersion) { - // directly return the block if: - // 1. builder is not enabled - // 2. failed to get builder payload - // 3. GLOAS: MEV-Boost blinded blocks not supported; builders use ePBS gossip bids - - // GLOAS: check p2p and configured Builder API bids against the local value. if stateVersion.AfterOrEqual(clparams.GloasVersion) { selfBid := beaconBody.SignedExecutionPayloadBid.Message options := gloasBlockOptionsFromContext(ctx) @@ -1849,7 +1844,13 @@ func (a *ApiHandler) postBeaconBlocks(w http.ResponseWriter, r *http.Request, ap if err != nil { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) } - if err := a.broadcastBlock(ctx, block.SignedBlock, validation); err != nil { + waitForIntegration := apiVersion == 2 + if err := a.broadcastBlockWithIntegrationWait(ctx, block.SignedBlock, validation, waitForIntegration); err != nil { + if errors.Is(err, errPublishedBlockAccepted) { + w.WriteHeader(http.StatusAccepted) + a.forwardPublishedBlockToBuilder(r.Header.Get("Eth-Builder-Url"), block.SignedBlock) + return newBeaconResponse(nil), nil + } if errors.Is(err, errPublishedBlockValidation) { return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err) } @@ -2080,7 +2081,7 @@ func (a *ApiHandler) publishBlindedBlocks(w http.ResponseWriter, r *http.Request } // broadcast the block - if err := a.broadcastBlock(r.Context(), signedBlock, BlockPublishingValidationGossip); err != nil { + if err := a.broadcastBlockWithIntegrationWait(r.Context(), signedBlock, BlockPublishingValidationGossip, false); err != nil { return nil, beaconhttp.NewEndpointError(http.StatusInternalServerError, err) } @@ -2207,11 +2208,15 @@ func readBoundedBody(body io.Reader, limit int64) ([]byte, error) { } func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeaconBlock, validation BlockPublishingValidation) error { + return a.broadcastBlockWithIntegrationWait(ctx, blk, validation, true) +} + +func (a *ApiHandler) broadcastBlockWithIntegrationWait(ctx context.Context, blk *cltypes.SignedBeaconBlock, validation BlockPublishingValidation, waitForIntegration bool) error { + if a.blockService == nil { + return errors.New("block integration service unavailable") + } releaseGossipReservation := false if validation == BlockPublishingValidationGossip { - if a.blockService == nil { - return errors.New("block gossip validator unavailable") - } if err := a.blockService.ValidateGossip(ctx, blk); err != nil { return fmt.Errorf("%w: %w", errPublishedBlockValidation, err) } @@ -2221,6 +2226,14 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac a.blockService.ReleaseGossipReservation(blk) } }() + } else { + rejectEquivocation := validation == BlockPublishingValidationConsensusAndEquivocation + if err := a.forkchoiceStore.ValidateBlockForPublishing(blk, rejectEquivocation); err != nil { + if errors.Is(err, forkchoice.ErrBlockInvalid) { + return fmt.Errorf("%w: %w", errPublishedBlockValidation, err) + } + return err + } } blkSSZ, err := blk.EncodeSSZ(nil) if err != nil { @@ -2328,12 +2341,6 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac store := func(ctx context.Context) error { return a.storeBlockAndBlobs(ctx, blk, blobsSidecars, columnsSidecars, validation) } - if validation != BlockPublishingValidationGossip { - if err := store(ctx); err != nil { - return err - } - } - lenBlobs := 0 if blk.Version() >= clparams.DenebVersion { if c := blk.Block.Body.GetBlobKzgCommitments(); c != nil { @@ -2358,8 +2365,8 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac } if validation == BlockPublishingValidationGossip { a.blockService.CommitGossipReservation(blk) - a.blockService.SchedulePublishedBlockForLaterProcessing(blk, store) } + job := a.blockService.SchedulePublishedBlockForLaterProcessing(blk, store) if blk.Version() < clparams.FuluVersion { for idx, blob := range blobsSidecarsBytes { @@ -2389,6 +2396,17 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac } } releaseGossipReservation = false + if waitForIntegration { + if job == nil { + return errors.New("block integration job unavailable") + } + if err := job.Wait(ctx); err != nil { + if errors.Is(err, forkchoice.ErrBlockInvalid) { + return fmt.Errorf("%w: %w", errPublishedBlockAccepted, err) + } + return err + } + } return nil } @@ -2473,14 +2491,14 @@ func (a *ApiHandler) storeBlockAndBlobs( if _, exists := a.forkchoiceStore.GetHeader(blockRoot); exists { stored, ok := a.forkchoiceStore.GetBlock(blockRoot) if !ok || !rootKeyedSignedBlockReplayMatches(stored, block) { - return fmt.Errorf("%w: published block does not match the validated block for its root", errPublishedBlockValidation) + return fmt.Errorf("%w: published block does not match the validated block for its root", forkchoice.ErrBlockInvalid) } knownBlock = true if rejectEquivocation && a.forkchoiceStore.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, blockRoot) { - return fmt.Errorf("%w: block conflicts with a previously validated proposal", errPublishedBlockValidation) + return fmt.Errorf("%w: block conflicts with a previously validated proposal", forkchoice.ErrBlockInvalid) } } else if validation != BlockPublishingValidationGossip && block.Block.Slot <= a.forkchoiceStore.FinalizedSlot() { - return fmt.Errorf("%w: block is at or below the finalized validation horizon", errPublishedBlockValidation) + return fmt.Errorf("%w: block is at or below the finalized validation horizon", forkchoice.ErrBlockInvalid) } if err := a.storeDataColumnSidecars(ctx, blockRoot, columnSidecars); err != nil { return fmt.Errorf("%w: %w", errPublishedBlockDataStorage, err) @@ -2501,7 +2519,7 @@ func (a *ApiHandler) storeBlockAndBlobs( blockErr = a.forkchoiceStore.OnBlock(ctx, block, true, true, false) } if blockErr != nil { - return fmt.Errorf("%w: %w", errPublishedBlockValidation, blockErr) + return blockErr } } diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go index 3647b0953a7..1e8c7f2f4bc 100644 --- a/cl/beacon/handler/block_production_test.go +++ b/cl/beacon/handler/block_production_test.go @@ -91,6 +91,20 @@ type replayableBlockService struct { scheduled chan struct{} } +type completedPublishedBlockJob struct { + err error +} + +func (j completedPublishedBlockJob) Wait(context.Context) error { return j.err } + +type synchronousPublishedBlockService struct { + *replayableBlockService +} + +func (s *synchronousPublishedBlockService) SchedulePublishedBlockForLaterProcessing(_ *cltypes.SignedBeaconBlock, store func(context.Context) error) clservices.PublishedBlockJob { + return completedPublishedBlockJob{err: store(context.Background())} +} + func (s *replayableBlockService) ValidateGossip(_ context.Context, block *cltypes.SignedBeaconBlock) error { root, err := block.HashSSZ() if err != nil { @@ -144,8 +158,9 @@ func (s *replayableBlockService) ScheduleBlockForLaterProcessing(*cltypes.Signed s.scheduled <- struct{}{} } -func (s *replayableBlockService) SchedulePublishedBlockForLaterProcessing(*cltypes.SignedBeaconBlock, func(context.Context) error) { +func (s *replayableBlockService) SchedulePublishedBlockForLaterProcessing(*cltypes.SignedBeaconBlock, func(context.Context) error) clservices.PublishedBlockJob { s.scheduled <- struct{}{} + return completedPublishedBlockJob{} } type failFirstSidecarGossip struct { @@ -176,9 +191,18 @@ type conflictAfterValidationForkchoice struct { conflict bool } +type rejectingOnBlockForkchoice struct { + forkchoice.ForkChoiceStorage + err error +} + +func (f *rejectingOnBlockForkchoice) OnBlock(context.Context, *cltypes.SignedBeaconBlock, bool, bool, bool) error { + return f.err +} + func (f *conflictAfterValidationForkchoice) OnBlockWithEquivocationCheck(context.Context, *cltypes.SignedBeaconBlock, bool, bool, bool) error { f.conflict = true - return nil + return fmt.Errorf("%w: block conflicts with a previously validated proposal", forkchoice.ErrBlockInvalid) } func (f *conflictAfterValidationForkchoice) HasBlockEquivocation(uint64, uint64, common.Hash) bool { @@ -764,6 +788,11 @@ func TestPostEthV2BeaconBlocksForwardsGloasBlockToWinningBuilder(t *testing.T) { require.NoError(t, err) ctrl := gomock.NewController(t) + blockService := network_services_mock.NewMockBlockService(ctrl) + blockService.EXPECT().ValidateGossip(gomock.Any(), gomock.Any()).Return(nil) + blockService.EXPECT().CommitGossipReservation(gomock.Any()) + blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(gomock.Any(), gomock.Any()).Return(completedPublishedBlockJob{}) + handler.blockService = blockService builderClient := builder_mock.NewMockBuilderClient(ctrl) builderURL := "https://builder.example" blockRoot, err := block.Block.HashSSZ() @@ -807,7 +836,13 @@ func TestPostEthV2BeaconBlocksForwardsUnboundBuilderRoute(t *testing.T) { require.NoError(t, err) builderURL := "https://builder.example" forwarded := make(chan struct{}) - client := builder_mock.NewMockBuilderClient(gomock.NewController(t)) + ctrl := gomock.NewController(t) + blockService := network_services_mock.NewMockBlockService(ctrl) + blockService.EXPECT().ValidateGossip(gomock.Any(), gomock.Any()).Return(nil) + blockService.EXPECT().CommitGossipReservation(gomock.Any()) + blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(gomock.Any(), gomock.Any()).Return(completedPublishedBlockJob{}) + handler.blockService = blockService + client := builder_mock.NewMockBuilderClient(ctrl) client.EXPECT().SubmitSignedBeaconBlockPublic(gomock.Any(), builderURL, gomock.Any()).DoAndReturn( func(context.Context, string, *cltypes.SignedBeaconBlock) error { close(forwarded) @@ -830,6 +865,173 @@ func TestPostEthV2BeaconBlocksForwardsUnboundBuilderRoute(t *testing.T) { } } +func TestPostEthV2BeaconBlocksReturnsAcceptedAndForwardsBuilderAfterPermanentIntegrationFailure(t *testing.T) { + _, _, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + forkchoiceStore.OnTickFn = func(uint64) {} + handler.beaconChainCfg.GloasForkEpoch = 0 + handler.beaconChainCfg.InitializeForkSchedule() + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + block.Block.Slot = 1 + block.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = 1 + body, err := json.Marshal(block) + require.NoError(t, err) + + validationErr := fmt.Errorf("%w: execution payload transactions are invalid", forkchoice.ErrBlockInvalid) + handler.forkchoiceStore = &rejectingOnBlockForkchoice{ForkChoiceStorage: forkchoiceStore, err: validationErr} + blockService := &replayableBlockService{ + MockBlockService: network_services_mock.NewMockBlockService(gomock.NewController(t)), + pending: make(map[publishingBlockKey]common.Hash), + seen: make(map[publishingBlockKey]publishingSeenBlock), + scheduled: make(chan struct{}, 1), + } + handler.blockService = &synchronousPublishedBlockService{replayableBlockService: blockService} + handler.gossipManager = &failFirstSidecarGossip{} + + builderURL := "https://builder.example" + forwarded := make(chan struct{}) + client := builder_mock.NewMockBuilderClient(gomock.NewController(t)) + client.EXPECT().SubmitSignedBeaconBlockPublic(gomock.Any(), builderURL, gomock.Any()).DoAndReturn( + func(context.Context, string, *cltypes.SignedBeaconBlock) error { + close(forwarded) + return nil + }, + ) + handler.builderClient = client + + recorder := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks?broadcast_validation=gossip", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Eth-Consensus-Version", clparams.GloasVersion.String()) + req.Header.Set("Eth-Builder-Url", builderURL) + + _, err = handler.PostEthV2BeaconBlocks(recorder, req) + require.NoError(t, err) + require.Equal(t, http.StatusAccepted, recorder.Code) + select { + case <-forwarded: + case <-time.After(time.Second): + t.Fatal("accepted block was not forwarded to the builder") + } +} + +func TestPostEthV2BeaconBlocksConsensusRejectsPreflightFailureBeforeGossip(t *testing.T) { + _, _, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + handler.beaconChainCfg.GloasForkEpoch = 0 + handler.beaconChainCfg.InitializeForkSchedule() + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + block.Block.Slot = 1 + body, err := json.Marshal(block) + require.NoError(t, err) + forkchoiceStore.ValidateBlockForPublishingFn = func(got *cltypes.SignedBeaconBlock, rejectEquivocation bool) error { + require.Equal(t, block.Block.Slot, got.Block.Slot) + require.False(t, rejectEquivocation) + return fmt.Errorf("%w: invalid consensus transition", forkchoice.ErrBlockInvalid) + } + handler.gossipManager = gossip_mock.NewMockGossip(gomock.NewController(t)) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks?broadcast_validation=consensus", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Eth-Consensus-Version", clparams.GloasVersion.String()) + _, err = handler.PostEthV2BeaconBlocks(httptest.NewRecorder(), req) + var endpointErr *beaconhttp.EndpointError + require.ErrorAs(t, err, &endpointErr) + require.Equal(t, http.StatusBadRequest, endpointErr.Code) +} + +func TestPostEthV2BeaconBlocksConsensusBroadcastsBeforeExecutionInvalidation(t *testing.T) { + _, _, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + forkchoiceStore.OnTickFn = func(uint64) {} + handler.beaconChainCfg.GloasForkEpoch = 0 + handler.beaconChainCfg.InitializeForkSchedule() + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + block.Block.Slot = 1 + body, err := json.Marshal(block) + require.NoError(t, err) + preflightCalls := 0 + forkchoiceStore.ValidateBlockForPublishingFn = func(*cltypes.SignedBeaconBlock, bool) error { + preflightCalls++ + return nil + } + handler.forkchoiceStore = &rejectingOnBlockForkchoice{ + ForkChoiceStorage: forkchoiceStore, + err: fmt.Errorf("%w: execution payload transactions are invalid", forkchoice.ErrBlockInvalid), + } + blockService := &replayableBlockService{ + MockBlockService: network_services_mock.NewMockBlockService(gomock.NewController(t)), + pending: make(map[publishingBlockKey]common.Hash), + seen: make(map[publishingBlockKey]publishingSeenBlock), + scheduled: make(chan struct{}, 1), + } + handler.blockService = &synchronousPublishedBlockService{replayableBlockService: blockService} + gossipManager := gossip_mock.NewMockGossip(gomock.NewController(t)) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, gomock.Any()).Return(nil) + handler.gossipManager = gossipManager + + recorder := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks?broadcast_validation=consensus", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Eth-Consensus-Version", clparams.GloasVersion.String()) + _, err = handler.PostEthV2BeaconBlocks(recorder, req) + require.NoError(t, err) + require.Equal(t, http.StatusAccepted, recorder.Code) + require.Equal(t, 1, preflightCalls) +} + +func TestPostEthV2BeaconBlocksReturnsServerErrorForTransientIntegrationFailure(t *testing.T) { + _, _, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) + forkchoiceStore.OnTickFn = func(uint64) {} + handler.beaconChainCfg.GloasForkEpoch = 0 + handler.beaconChainCfg.InitializeForkSchedule() + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion) + block.Block.Slot = 1 + body, err := json.Marshal(block) + require.NoError(t, err) + handler.forkchoiceStore = &rejectingOnBlockForkchoice{ForkChoiceStorage: forkchoiceStore, err: errors.New("database unavailable")} + blockService := &replayableBlockService{ + MockBlockService: network_services_mock.NewMockBlockService(gomock.NewController(t)), + pending: make(map[publishingBlockKey]common.Hash), + seen: make(map[publishingBlockKey]publishingSeenBlock), + scheduled: make(chan struct{}, 1), + } + handler.blockService = &synchronousPublishedBlockService{replayableBlockService: blockService} + handler.gossipManager = &failFirstSidecarGossip{} + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks?broadcast_validation=gossip", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Eth-Consensus-Version", clparams.GloasVersion.String()) + _, err = handler.PostEthV2BeaconBlocks(httptest.NewRecorder(), req) + var endpointErr *beaconhttp.EndpointError + require.ErrorAs(t, err, &endpointErr) + require.Equal(t, http.StatusInternalServerError, endpointErr.Code) +} + +func TestPostEthV1BeaconBlocksDoesNotWaitForFullIntegration(t *testing.T) { + _, _, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + forkchoiceStore.OnTickFn = func(uint64) {} + version := handler.beaconChainCfg.GetCurrentStateVersion(handler.ethClock.GetCurrentEpoch()) + block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, version) + block.Block.Slot = 1 + body, err := json.Marshal(&cltypes.DenebSignedBeaconBlock{SignedBlock: block}) + require.NoError(t, err) + handler.forkchoiceStore = &rejectingOnBlockForkchoice{ + ForkChoiceStorage: forkchoiceStore, + err: fmt.Errorf("%w: integration rejected", forkchoice.ErrBlockInvalid), + } + blockService := &replayableBlockService{ + MockBlockService: network_services_mock.NewMockBlockService(gomock.NewController(t)), + pending: make(map[publishingBlockKey]common.Hash), + seen: make(map[publishingBlockKey]publishingSeenBlock), + scheduled: make(chan struct{}, 1), + } + handler.blockService = &synchronousPublishedBlockService{replayableBlockService: blockService} + handler.gossipManager = &failFirstSidecarGossip{} + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/blocks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + _, err = handler.PostEthV1BeaconBlocks(httptest.NewRecorder(), req) + require.NoError(t, err) +} + func TestForwardPublishedBlockToBuilderOnlyOnce(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true) handler.beaconChainCfg.GloasForkEpoch = 0 @@ -1124,7 +1326,7 @@ func TestBroadcastBlockRejectsUnknownBlockAtFinalizedHorizonBeforeWrites(t *test } } -func TestBroadcastBlockRechecksEquivocationAfterStoreBeforePublish(t *testing.T) { +func TestBroadcastBlockReportsAcceptedWhenEquivocationAppearsDuringIntegration(t *testing.T) { ctrl := gomock.NewController(t) _, blocks, _, _, postState, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) block := blocks[1] @@ -1135,15 +1337,14 @@ func TestBroadcastBlockRechecksEquivocationAfterStoreBeforePublish(t *testing.T) fcu.HeadSlotVal = block.Block.Slot fcu.OnTickFn = func(uint64) {} handler.forkchoiceStore = &conflictAfterValidationForkchoice{ForkChoiceStorage: fcu} - engine := execution_client.NewMockExecutionEngine(ctrl) - engine.EXPECT().ForkChoiceUpdate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil) - handler.engine = engine handler.attestationProducer = attestation_producer.New(t.Context(), handler.beaconChainCfg) - handler.gossipManager = gossip_mock.NewMockGossip(ctrl) + gossipManager := gossip_mock.NewMockGossip(ctrl) + gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, gomock.Any()).Return(nil) + handler.gossipManager = gossipManager err = handler.broadcastBlock(t.Context(), block, BlockPublishingValidationConsensusAndEquivocation) - require.ErrorIs(t, err, errPublishedBlockValidation) + require.ErrorIs(t, err, errPublishedBlockAccepted) require.ErrorContains(t, err, "conflicts with a previously validated proposal") } @@ -1233,8 +1434,9 @@ func TestBroadcastBlockSchedulesFullRecoveryAfterBlobStorageFailure(t *testing.T blockService.EXPECT().ReleaseGossipReservation(block) blockService.EXPECT().ScheduleBlockForLaterProcessing(block).Times(0) scheduledStore := make(chan func(context.Context) error, 1) - blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(block, gomock.Any()).Do(func(_ *cltypes.SignedBeaconBlock, store func(context.Context) error) { + blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(block, gomock.Any()).DoAndReturn(func(_ *cltypes.SignedBeaconBlock, store func(context.Context) error) clservices.PublishedBlockJob { scheduledStore <- store + return completedPublishedBlockJob{} }) handler.blockService = blockService blobStorage := blob_storage_mock.NewMockBlobStorage(ctrl) @@ -1276,7 +1478,10 @@ func TestBroadcastBlockSchedulesRecoveryAfterSidecarsAndForkchoiceSucceed(t *tes blockService.EXPECT().ValidateGossip(gomock.Any(), block).Return(nil) blockService.EXPECT().CommitGossipReservation(block) scheduledStore := make(chan func(context.Context) error, 1) - blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(block, gomock.Any()).Do(func(_ *cltypes.SignedBeaconBlock, store func(context.Context) error) { scheduledStore <- store }) + blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(block, gomock.Any()).DoAndReturn(func(_ *cltypes.SignedBeaconBlock, store func(context.Context) error) clservices.PublishedBlockJob { + scheduledStore <- store + return completedPublishedBlockJob{} + }) handler.blockService = blockService gossipManager := gossip_mock.NewMockGossip(ctrl) gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, gomock.Any()).Return(nil) @@ -1317,6 +1522,44 @@ func TestStoreBlockAndBlobsDoesNotRepeatForkchoiceAfterDatabaseFailure(t *testin require.Equal(t, 1, installing.onBlockCalls) } +func TestStoreBlockAndBlobsClassifiesKnownRootMismatchAsPermanent(t *testing.T) { + _, blocks, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + stored := blocks[1] + root, err := stored.Block.HashSSZ() + require.NoError(t, err) + fcu.Headers[root] = stored.SignedBeaconBlockHeader().Header + fcu.Blocks[root] = stored + incoming := &cltypes.SignedBeaconBlock{Block: stored.Block, Signature: stored.Signature} + incoming.Signature[0] ^= 1 + + err = handler.storeBlockAndBlobs(t.Context(), incoming, nil, nil, BlockPublishingValidationConsensus) + require.ErrorIs(t, err, forkchoice.ErrBlockInvalid) +} + +func TestStoreBlockAndBlobsClassifiesFinalizedHorizonRaceAsPermanent(t *testing.T) { + _, blocks, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + block := blocks[1] + fcu.FinalizedSlotVal = block.Block.Slot + + err := handler.storeBlockAndBlobs(t.Context(), block, nil, nil, BlockPublishingValidationConsensus) + require.ErrorIs(t, err, forkchoice.ErrBlockInvalid) +} + +func TestStoreBlockAndBlobsClassifiesKnownEquivocationAsPermanent(t *testing.T) { + _, blocks, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true) + block := blocks[1] + root, err := block.Block.HashSSZ() + require.NoError(t, err) + fcu.Headers[root] = block.SignedBeaconBlockHeader().Header + fcu.Blocks[root] = block + conflict := block.SignedBeaconBlockHeader().Header.Copy() + conflict.Root[0] ^= 1 + fcu.Headers[common.Hash{0xff}] = conflict + + err = handler.storeBlockAndBlobs(t.Context(), block, nil, nil, BlockPublishingValidationConsensusAndEquivocation) + require.ErrorIs(t, err, forkchoice.ErrBlockInvalid) +} + func TestBroadcastBlockExactReplayCompletesMissingDataSidecars(t *testing.T) { for _, version := range []clparams.StateVersion{clparams.DenebVersion, clparams.FuluVersion} { t.Run(version.String(), func(t *testing.T) { diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index 1290cc9e5a2..816d94dfbae 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -794,6 +794,9 @@ func (a *ApiHandler) GetEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrite return nil, beaconhttp.NewEndpointError(http.StatusNotFound, fmt.Errorf("block not found for block root %v", blockRoot)) } + if a.beaconChainCfg.SlotsPerEpoch == 0 { + return nil, beaconhttp.NewEndpointError(http.StatusServiceUnavailable, errors.New("slots per epoch is zero")) + } slot := block.Block.Slot epoch := slot / a.beaconChainCfg.SlotsPerEpoch finalized := a.forkchoiceStore.FinalizedCheckpoint() diff --git a/cl/beacon/handler/epbs_test.go b/cl/beacon/handler/epbs_test.go index 7726c9594db..405276545d0 100644 --- a/cl/beacon/handler/epbs_test.go +++ b/cl/beacon/handler/epbs_test.go @@ -1054,6 +1054,19 @@ func TestGetExecutionPayloadEnvelopeFinalityBoundaryMatrix(t *testing.T) { } } +func TestGetExecutionPayloadEnvelopeRejectsZeroSlotsPerEpoch(t *testing.T) { + _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) + root := common.HexToHash("0x1234") + fcu.Envelopes[root] = &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} + fcu.Blocks[root] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 64, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}} + handler.beaconChainCfg.SlotsPerEpoch = 0 + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/execution_payload_envelope/"+root.Hex(), http.NoBody) + recorder := httptest.NewRecorder() + + require.NotPanics(t, func() { handler.ServeHTTP(recorder, request) }) + require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String()) +} + func TestPostExecutionPayloadEnvelopesClassifiesIntegrationFailureByValidationMode(t *testing.T) { for _, test := range []struct { validation string diff --git a/cl/beacon/handler/utils_test.go b/cl/beacon/handler/utils_test.go index c1d4fa77cac..e179610629d 100644 --- a/cl/beacon/handler/utils_test.go +++ b/cl/beacon/handler/utils_test.go @@ -132,7 +132,11 @@ func setupTestingHandler(t *testing.T, v clparams.StateVersion, logger log.Logge blockService.EXPECT().CommitGossipReservation(gomock.Any()).AnyTimes() blockService.EXPECT().ReleaseGossipReservation(gomock.Any()).AnyTimes() blockService.EXPECT().ScheduleBlockForLaterProcessing(gomock.Any()).AnyTimes() - blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(gomock.Any(), gomock.Any()).AnyTimes() + blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ *cltypes.SignedBeaconBlock, store func(context.Context) error) services.PublishedBlockJob { + return completedPublishedBlockJob{err: store(context.Background())} + }, + ).AnyTimes() // ctx context.Context, subnetID *uint64, msg *cltypes.SyncCommitteeMessage) error syncCommitteeMessagesService.EXPECT().ProcessMessage(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, subnetID *uint64, msg *services.SyncCommitteeMessageForGossip) error { diff --git a/cl/phase1/forkchoice/fork_choice_test.go b/cl/phase1/forkchoice/fork_choice_test.go index 7793b5ec415..f3355a11d62 100644 --- a/cl/phase1/forkchoice/fork_choice_test.go +++ b/cl/phase1/forkchoice/fork_choice_test.go @@ -113,6 +113,14 @@ func TestForkChoiceBasic(t *testing.T) { // first steps store.OnTick(0) store.OnTick(12) + blockRoot, err := block0x3a.Block.HashSSZ() + require.NoError(t, err) + require.NoError(t, store.ValidateBlockForPublishing(block0x3a, false)) + _, insertedByPreflight := store.GetHeader(blockRoot) + require.False(t, insertedByPreflight) + block0x3a.Block.StateRoot[0] ^= 1 + require.ErrorIs(t, store.ValidateBlockForPublishing(block0x3a, false), forkchoice.ErrBlockInvalid) + block0x3a.Block.StateRoot[0] ^= 1 require.NoError(t, store.OnBlock(ctx, block0x3a, false, true, false)) // Check if we get correct status (1) require.Equal(t, uint64(12), store.Time()) diff --git a/cl/phase1/forkchoice/interface.go b/cl/phase1/forkchoice/interface.go index de15c8c32d4..e7d0b179dea 100644 --- a/cl/phase1/forkchoice/interface.go +++ b/cl/phase1/forkchoice/interface.go @@ -143,6 +143,7 @@ type ForkChoiceStorageWriter interface { fullValidation bool, checkDataAvaibility bool, ) error + ValidateBlockForPublishing(block *cltypes.SignedBeaconBlock, rejectEquivocation bool) error // [New in Gloas:EIP7732] OnExecutionPayload processes an execution payload envelope from the builder. // checkBlobData: verify blob data availability via PeerDAS // validatePayload: call engine.NewPayload() to validate with EL diff --git a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go index beec2e23672..6e56ccd4a0a 100644 --- a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go +++ b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go @@ -19,6 +19,7 @@ package mock_services import ( "context" "errors" + "fmt" "testing" "go.uber.org/mock/gomock" @@ -81,6 +82,7 @@ type ForkChoiceStorageMock struct { VerifiedPayloads map[common.Hash]bool OnExecutionPayloadErr error OnExecutionPayloadFn func(context.Context, *cltypes.SignedExecutionPayloadEnvelope, bool, bool) error + ValidateBlockForPublishingFn func(*cltypes.SignedBeaconBlock, bool) error OnTickFn func(uint64) ValidateExecutionPayloadEnvelopeErr error GetBeaconCommitteeMock func(slot, committeeIndex uint64) ([]uint64, error) @@ -379,6 +381,28 @@ func (f *ForkChoiceStorageMock) OnBlockWithEquivocationCheck( return f.OnBlock(ctx, block, newPayload, fullValidation, checkDataAvaiability) } +func (f *ForkChoiceStorageMock) ValidateBlockForPublishing(block *cltypes.SignedBeaconBlock, rejectEquivocation bool) error { + if f.ValidateBlockForPublishingFn != nil { + return f.ValidateBlockForPublishingFn(block, rejectEquivocation) + } + if block == nil || block.Block == nil { + return fmt.Errorf("%w: missing beacon block", forkchoice.ErrBlockInvalid) + } + root, err := block.Block.HashSSZ() + if err != nil { + return fmt.Errorf("%w: %w", forkchoice.ErrBlockInvalid, err) + } + if _, known := f.Headers[root]; known { + stored, ok := f.Blocks[root] + if !ok || stored == nil || stored.Block == nil || stored.Version() != block.Version() || stored.Signature != block.Signature { + return fmt.Errorf("%w: published block does not match the validated block for its root", forkchoice.ErrBlockInvalid) + } + } else if block.Block.Slot <= f.FinalizedSlotVal { + return fmt.Errorf("%w: block is at or below the finalized validation horizon", forkchoice.ErrBlockInvalid) + } + return nil +} + func (f *ForkChoiceStorageMock) OnExecutionPayload(ctx context.Context, signedEnvelope *cltypes.SignedExecutionPayloadEnvelope, checkBlobData, validatePayload bool) error { if f.OnExecutionPayloadFn != nil { return f.OnExecutionPayloadFn(ctx, signedEnvelope, checkBlobData, validatePayload) diff --git a/cl/phase1/forkchoice/on_block.go b/cl/phase1/forkchoice/on_block.go index d07293dd7ca..26684087a2d 100644 --- a/cl/phase1/forkchoice/on_block.go +++ b/cl/phase1/forkchoice/on_block.go @@ -32,6 +32,7 @@ import ( "github.com/erigontech/erigon/cl/phase1/core/state" "github.com/erigontech/erigon/cl/phase1/execution_client" "github.com/erigontech/erigon/cl/phase1/forkchoice/fork_graph" + "github.com/erigontech/erigon/cl/transition" "github.com/erigontech/erigon/cl/transition/impl/eth2/statechange" "github.com/erigontech/erigon/cl/utils" "github.com/erigontech/erigon/cl/utils/eth_clock" @@ -86,6 +87,18 @@ func collectOnBlockLatencyToUnixTime(ethClock eth_clock.EthereumClock, slot, cur monitor.ObserveBlockImportingLatency(initialSlotTime) } +var ErrBlockInvalid = errors.New("beacon block is permanently invalid") + +var errBlockAtFinalizedHorizon = errors.New("beacon block is at or below the finalized horizon") + +func invalidBlockError(err error) error { + return fmt.Errorf("%w: %w", ErrBlockInvalid, err) +} + +func invalidKzgCommitmentsError(err error) error { + return invalidBlockError(fmt.Errorf("OnBlock: failed to process kzg commitments: %w", err)) +} + func (f *ForkChoiceStore) OnBlock(ctx context.Context, block *cltypes.SignedBeaconBlock, newPayload, fullValidation, checkDataAvaiability bool) error { return f.onBlock(ctx, block, newPayload, fullValidation, checkDataAvaiability, false) } @@ -94,6 +107,93 @@ func (f *ForkChoiceStore) OnBlockWithEquivocationCheck(ctx context.Context, bloc return f.onBlock(ctx, block, newPayload, fullValidation, checkDataAvaiability, true) } +func (f *ForkChoiceStore) ValidateBlockForPublishing(block *cltypes.SignedBeaconBlock, rejectEquivocation bool) error { + if block == nil || block.Block == nil || block.Block.Body == nil { + return invalidBlockError(errors.New("missing beacon block")) + } + f.mu.RLock() + knownRoot, err := block.Block.HashSSZ() + if err != nil { + f.mu.RUnlock() + return invalidBlockError(err) + } + if _, known := f.forkGraph.GetHeader(knownRoot); known { + stored, ok := f.forkGraph.GetBlock(knownRoot) + if !ok || stored == nil || stored.Block == nil || stored.Version() != block.Version() || stored.Signature != block.Signature { + f.mu.RUnlock() + return invalidBlockError(errors.New("published block does not match the validated block for its root")) + } + if rejectEquivocation && f.forkGraph.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, knownRoot) { + f.mu.RUnlock() + return invalidBlockError(errors.New("block conflicts with a previously validated proposal")) + } + f.mu.RUnlock() + return nil + } + blockRoot, _, err := f.validateBlockAdmissionLocked(block, rejectEquivocation) + if err != nil { + f.mu.RUnlock() + if errors.Is(err, errBlockAtFinalizedHorizon) { + return invalidBlockError(err) + } + return err + } + if _, known := f.forkGraph.GetHeader(blockRoot); known { + f.mu.RUnlock() + return nil + } + parentState, err := f.forkGraph.GetState(block.Block.ParentRoot, true) + f.mu.RUnlock() + if err != nil { + return err + } + if parentState == nil { + return ErrMissingSegment + } + if err := transition.TransitionState(parentState, block, nil, true); err != nil { + return invalidBlockError(err) + } + return nil +} + +func (f *ForkChoiceStore) validateBlockAdmissionLocked(block *cltypes.SignedBeaconBlock, rejectEquivocation bool) (common.Hash, clparams.StateVersion, error) { + blockRoot, err := block.Block.HashSSZ() + if err != nil { + return common.Hash{}, 0, invalidBlockError(err) + } + root := common.Hash(blockRoot) + if rejectEquivocation && f.forkGraph.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, root) { + return common.Hash{}, 0, invalidBlockError(errors.New("block conflicts with a previously validated proposal")) + } + if f.Slot() < block.Block.Slot { + return common.Hash{}, 0, invalidBlockError(errors.New("block is too early compared to current_slot")) + } + finalizedCheckpoint := f.finalizedCheckpoint.Load().(solid.Checkpoint) + finalizedSlot := f.computeStartSlotAtEpoch(finalizedCheckpoint.Epoch) + if anchorSlot := f.forkGraph.AnchorSlot(); finalizedSlot < anchorSlot { + finalizedSlot = anchorSlot + } + if block.Block.Slot <= finalizedSlot { + return common.Hash{}, 0, errBlockAtFinalizedHorizon + } + if ancestorNode := f.getAncestor(ForkChoiceNode{Root: block.Block.ParentRoot, PayloadStatus: cltypes.PayloadStatusPending}, finalizedSlot); ancestorNode.Root != finalizedCheckpoint.Root { + return common.Hash{}, 0, invalidBlockError(ErrNotFinalizedDescendant) + } + if !f.beaconCfg.ForkSchemaMatchesSlot(block.Block.Slot, block.Version()) { + return common.Hash{}, 0, invalidBlockError(ErrForkSchemaSlotMismatch) + } + blockVersion := f.beaconCfg.GetCurrentStateVersion(f.computeEpochAtSlot(block.Block.Slot)) + if blockVersion >= clparams.GloasVersion { + if err := f.validateParentPayloadPath(block.Block); err != nil { + if errors.Is(err, ErrParentEnvelopePending) { + return common.Hash{}, 0, err + } + return common.Hash{}, 0, invalidBlockError(err) + } + } + return root, blockVersion, nil +} + func (f *ForkChoiceStore) onBlock(ctx context.Context, block *cltypes.SignedBeaconBlock, newPayload, fullValidation, checkDataAvaiability, rejectEquivocation bool) error { f.mu.Lock() unlocked := false @@ -104,52 +204,34 @@ func (f *ForkChoiceStore) onBlock(ctx context.Context, block *cltypes.SignedBeac } }() start := time.Now() - blockRoot, err := block.Block.HashSSZ() - if err != nil { - return err + if block == nil || block.Block == nil || block.Block.Body == nil { + return invalidBlockError(errors.New("missing beacon block")) } - if rejectEquivocation && f.forkGraph.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, blockRoot) { - return errors.New("block conflicts with a previously validated proposal") + knownRoot, err := block.Block.HashSSZ() + if err != nil { + return invalidBlockError(err) } if block.Version() >= clparams.GloasVersion { - if _, ok := f.forkGraph.GetHeader(blockRoot); ok { + if _, known := f.forkGraph.GetHeader(knownRoot); known { + if rejectEquivocation && f.forkGraph.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, knownRoot) { + return invalidBlockError(errors.New("block conflicts with a previously validated proposal")) + } return nil } } - f.headHash = common.Hash{} - f.headPayloadStatus = cltypes.PayloadStatusPending - // Use the store's current slot (set via OnTick) to validate the block is not from the future. - // The spec says: assert get_current_slot(store) >= block.slot - if f.Slot() < block.Block.Slot { - return errors.New("block is too early compared to current_slot") - } - - // Check that block is later than the finalized epoch slot (optimization to reduce calls to get_ancestor) - finalizedCheckpoint := f.finalizedCheckpoint.Load().(solid.Checkpoint) - finalizedSlot := f.computeStartSlotAtEpoch(finalizedCheckpoint.Epoch) - // After checkpoint sync, the anchor block may sit inside (not at the start of) - // the finalized epoch. The fork graph only contains the anchor and its descendants, - // so Ancestor() cannot trace past the anchor. Cap finalizedSlot to the anchor slot - // so the descendant check stays within the fork graph's horizon. - if anchorSlot := f.forkGraph.AnchorSlot(); finalizedSlot < anchorSlot { - finalizedSlot = anchorSlot - } - if block.Block.Slot <= finalizedSlot { + blockRoot, blockVersion, err := f.validateBlockAdmissionLocked(block, rejectEquivocation) + if errors.Is(err, errBlockAtFinalizedHorizon) { return nil } - // Check block is a descendant of the finalized block at the checkpoint finalized slot - if ancestorNode := f.Ancestor(block.Block.ParentRoot, finalizedSlot); ancestorNode.Root != finalizedCheckpoint.Root { - return ErrNotFinalizedDescendant + if err != nil { + return err } + f.headHash = common.Hash{} + f.headPayloadStatus = cltypes.PayloadStatusPending currentSlotOnEntry := f.ethClock.GetCurrentSlot() - if !f.beaconCfg.ForkSchemaMatchesSlot(block.Block.Slot, block.Version()) { - return ErrForkSchemaSlotMismatch - } - // Validate parent payload status path early (before expensive operations) blockEpoch := f.computeEpochAtSlot(block.Block.Slot) - blockVersion := f.beaconCfg.GetCurrentStateVersion(blockEpoch) isGloas := blockVersion >= clparams.GloasVersion headBeforeBlock := common.Hash{} if isGloas && f.Slot() == block.Block.Slot { @@ -161,11 +243,6 @@ func (f *ForkChoiceStore) onBlock(ctx context.Context, block *cltypes.SignedBeac } headBeforeBlock = head.Root } - if isGloas { - if err := f.validateParentPayloadPath(block.Block); err != nil { - return err - } - } // Pre-GLOAS execution payload processing. // In GLOAS, ExecutionPayload and BlobKzgCommitments are nil in BeaconBlock. @@ -239,7 +316,7 @@ func (f *ForkChoiceStore) onBlock(ctx context.Context, block *cltypes.SignedBeac if newPayload && f.engine != nil && !isVerifiedExecutionPayload { if block.Version() >= clparams.DenebVersion { if err := verifyKzgCommitmentsAgainstTransactions(f.beaconCfg, block.Block); err != nil { - return fmt.Errorf("OnBlock: failed to process kzg commitments: %w", err) + return invalidKzgCommitmentsError(err) } } payloadStatus, err := f.NewPayloadWithAdmission(ctx, block.Block.Body.ExecutionPayload, &block.Block.ParentRoot, versionedHashes, executionRequestsList) @@ -267,7 +344,7 @@ func (f *ForkChoiceStore) onBlock(ctx context.Context, block *cltypes.SignedBeac if err := f.optimisticStore.InvalidateBlock(blockRoot, block.Block); err != nil { return fmt.Errorf("failed to remove block from optimistic store: %w", err) } - return errors.New("block is invalid") + return invalidBlockError(errors.New("execution payload is invalid")) case execution_client.PayloadStatusValidated: log.Trace("OnBlock: block is validated", "block", common.Hash(blockRoot)) // remove from optimistic candidate @@ -300,6 +377,9 @@ func (f *ForkChoiceStore) onBlock(ctx context.Context, block *cltypes.SignedBeac lastProcessedState, status, err := f.addChainSegmentAndQueueLightClientEvents(block, fullValidation) if err != nil { + if status == fork_graph.InvalidBlock { + return invalidBlockError(err) + } return err } monitor.ObserveFullBlockProcessingTime(startStateProcess) diff --git a/cl/phase1/forkchoice/on_block_fork_consistency_test.go b/cl/phase1/forkchoice/on_block_fork_consistency_test.go index 77ae720ab64..bbb62f15a1a 100644 --- a/cl/phase1/forkchoice/on_block_fork_consistency_test.go +++ b/cl/phase1/forkchoice/on_block_fork_consistency_test.go @@ -18,6 +18,7 @@ package forkchoice import ( "context" + "errors" "testing" "github.com/stretchr/testify/require" @@ -28,6 +29,13 @@ import ( "github.com/erigontech/erigon/common" ) +func TestKzgCommitmentValidationErrorIsPermanent(t *testing.T) { + cause := errors.New("blob hash mismatch") + err := invalidKzgCommitmentsError(cause) + require.ErrorIs(t, err, ErrBlockInvalid) + require.ErrorIs(t, err, cause) +} + // A response's decoded schema comes from the peer-chosen fork digest, so it is // independent of the slot the block claims. Gloas removed ExecutionPayload and // BlobKzgCommitments from BeaconBody, so a Gloas-decoded block whose slot maps diff --git a/cl/phase1/network/backward_beacon_downloader.go b/cl/phase1/network/backward_beacon_downloader.go index aba7d697cf5..cc4f5999083 100644 --- a/cl/phase1/network/backward_beacon_downloader.go +++ b/cl/phase1/network/backward_beacon_downloader.go @@ -20,7 +20,6 @@ import ( "context" "errors" "fmt" - "io" "net/http" "slices" "strings" @@ -678,8 +677,7 @@ func fetchBlockFromBeaconAPIByRoot(ctx context.Context, baseURL string, root com if err != nil { return nil, err } - body, err := io.ReadAll(resp.Body) - resp.Body.Close() + body, err := readBeaconAPIResponseBody(resp) if err != nil { return nil, err } @@ -690,11 +688,27 @@ func fetchBlockFromBeaconAPIByRoot(ctx context.Context, baseURL string, root com return nil, fmt.Errorf("block fetch by root: status %d", resp.StatusCode) } - version := httpConsensusVersion(resp.Header.Get("Eth-Consensus-Version")) + version, err := httpConsensusVersion(resp.Header.Get("Eth-Consensus-Version")) + if err != nil { + return nil, err + } block := cltypes.NewSignedBeaconBlock(beaconCfg, version) - if err := block.DecodeSSZ(body, int(version)); err != nil { + if err := block.DecodeSSZStrict(body, int(version)); err != nil { return nil, fmt.Errorf("block decode by root: %w", err) } + if block.Block == nil { + return nil, errors.New("block fetched by root has no message") + } + if err := validateHTTPBlockVersion(beaconCfg, block.Block.Slot, version); err != nil { + return nil, err + } + decodedRoot, err := block.Block.HashSSZ() + if err != nil { + return nil, fmt.Errorf("block root: %w", err) + } + if decodedRoot != root { + return nil, fmt.Errorf("block root mismatch: requested %v, received %v", root, decodedRoot) + } return block, nil } @@ -705,9 +719,16 @@ func (b *BackwardBeaconDownloader) fetchSingleEnvelope(ctx context.Context, bloc if b.httpFallbackURL == "" { return nil, fmt.Errorf("no HTTP fallback URL configured") } + if block == nil || block.Block == nil { + return nil, errors.New("cannot fetch envelope for nil block") + } + blockRoot, err := block.Block.HashSSZ() + if err != nil { + return nil, fmt.Errorf("block root: %w", err) + } client := &http.Client{Timeout: 10 * time.Second} - reqURL := fmt.Sprintf("%s/eth/v1/beacon/execution_payload_envelope/%d", b.httpFallbackURL, block.Block.Slot) + reqURL := fmt.Sprintf("%s/eth/v1/beacon/execution_payload_envelope/0x%x", b.httpFallbackURL, blockRoot) req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, err @@ -718,8 +739,7 @@ func (b *BackwardBeaconDownloader) fetchSingleEnvelope(ctx context.Context, bloc if err != nil { return nil, err } - body, err := io.ReadAll(resp.Body) - resp.Body.Close() + body, err := readBeaconAPIResponseBody(resp) if err != nil { return nil, err } @@ -729,12 +749,25 @@ func (b *BackwardBeaconDownloader) fetchSingleEnvelope(ctx context.Context, bloc if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("envelope fetch: HTTP %d", resp.StatusCode) } + version, err := httpConsensusVersion(resp.Header.Get("Eth-Consensus-Version")) + if err != nil { + return nil, err + } + if version != clparams.GloasVersion { + return nil, fmt.Errorf("envelope version mismatch: expected %s, received %s", clparams.GloasVersion, version) + } + if err := validateHTTPBlockVersion(b.beaconCfg, block.Block.Slot, version); err != nil { + return nil, err + } envelope := &cltypes.SignedExecutionPayloadEnvelope{ Message: cltypes.NewExecutionPayloadEnvelope(b.beaconCfg), } - if err := envelope.DecodeSSZ(body, int(clparams.GloasVersion)); err != nil { + if err := envelope.DecodeSSZStrict(body, int(clparams.GloasVersion)); err != nil { return nil, fmt.Errorf("envelope decode: %w", err) } + if envelope.Message == nil || envelope.Message.BeaconBlockRoot != blockRoot { + return nil, fmt.Errorf("envelope block root mismatch: requested %v", blockRoot) + } return envelope, nil } diff --git a/cl/phase1/network/backward_beacon_downloader_test.go b/cl/phase1/network/backward_beacon_downloader_test.go index 5d5a10e0b14..82f1cdb7657 100644 --- a/cl/phase1/network/backward_beacon_downloader_test.go +++ b/cl/phase1/network/backward_beacon_downloader_test.go @@ -224,3 +224,43 @@ func TestBackwardBeaconDownloaderHTTPPreferredEmptyResponseFallsBack(t *testing. t.Fatal("httpPreferred remained true after empty HTTP response") } } + +func TestFetchBlockFromBeaconAPIByRootRejectsDifferentBlock(t *testing.T) { + block := makeGloasBlock(10, hash(0xaa), common.Hash{}) + encoded, err := block.EncodeSSZ(nil) + require.NoError(t, err) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encoded) + })) + defer server.Close() + + otherRoot := common.HexToHash("0xdead") + fetched, err := fetchBlockFromBeaconAPIByRoot(t.Context(), server.URL, otherRoot, gloasFromGenesisConfig()) + require.ErrorContains(t, err, "root") + require.Nil(t, fetched) +} + +func TestBackwardBeaconDownloaderFetchEnvelopeUsesRootAndRejectsIdentityMismatch(t *testing.T) { + cfg := gloasFromGenesisConfig() + block := makeGloasBlock(10, hash(0xaa), common.Hash{}) + blockRoot, err := block.Block.HashSSZ() + require.NoError(t, err) + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(cfg)} + envelope.Message.BeaconBlockRoot = common.HexToHash("0xdead") + encoded, err := envelope.EncodeSSZ(nil) + require.NoError(t, err) + requestedPath := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPath <- r.URL.Path + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encoded) + })) + defer server.Close() + downloader := &BackwardBeaconDownloader{httpFallbackURL: server.URL, beaconCfg: cfg} + + fetched, err := downloader.fetchSingleEnvelope(t.Context(), block) + require.ErrorContains(t, err, "root mismatch") + require.Nil(t, fetched) + require.Equal(t, "/eth/v1/beacon/execution_payload_envelope/"+common.Hash(blockRoot).Hex(), <-requestedPath) +} diff --git a/cl/phase1/network/beacon_downloader.go b/cl/phase1/network/beacon_downloader.go index 9860617357b..f8ec66cb11d 100644 --- a/cl/phase1/network/beacon_downloader.go +++ b/cl/phase1/network/beacon_downloader.go @@ -69,6 +69,8 @@ type ForwardBeaconDownloader struct { gloasNextUnscanned uint64 } +const forwardRequestRetryInterval = 300 * time.Millisecond + // SetCurrentSlotSampler limits range requests to slots that can already exist. func (f *ForwardBeaconDownloader) SetCurrentSlotSampler(currentSlot func() uint64) { f.currentSlot = currentSlot @@ -146,6 +148,7 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { count := uint64(32) requestStart, _ := f.nextRequestStart(true) if f.capAtCurrentSlot(requestStart, 1) == 0 { + waitForForwardRequestRetry(ctx) return } var atomicResp atomic.Value @@ -156,6 +159,10 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { httpStart, hadGloasPending := f.nextRequestStart(false) httpCount := capRequestCount(httpStart, count+10) httpCount = f.capAtCurrentSlot(httpStart, httpCount) + if httpCount == 0 { + waitForForwardRequestRetry(ctx) + return + } httpBlocks, httpErr := fetchBlocksFromBeaconAPI(ctx, f.httpFallbackURL, httpStart, httpCount, f.beaconCfg) switch { case httpErr == nil && len(httpBlocks) > 0: @@ -171,10 +178,14 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { goto Process } } + if f.requestBlocksByRange == nil { + waitForForwardRequestRetry(ctx) + return + } { // Start with a base interval; backoff increases it on repeated failures. - baseInterval := 300 * time.Millisecond + baseInterval := forwardRequestRetryInterval var consecutiveFailures atomic.Int32 var requestsMu sync.Mutex inFlightRequests := 0 @@ -184,6 +195,7 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { apply func() } var pendingEmpty *emptyRangeResult + noRequestableRange := make(chan struct{}, 1) beginRequest := func() bool { requestsMu.Lock() defer requestsMu.Unlock() @@ -245,6 +257,13 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { reqSlot, reqCount = f.capAtForkBoundary(reqSlot, reqCount) } reqCount = f.capAtCurrentSlot(reqSlot, reqCount) + if reqCount == 0 { + select { + case noRequestableRange <- struct{}{}: + default: + } + return + } // leave a warning if we are stuck for more than 90 seconds if time.Since(f.highestSlotUpdateTime) > 90*time.Second { @@ -274,6 +293,13 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { httpStart, httpHadGloasPending := f.nextRequestStart(false) httpCount := capRequestCount(httpStart, count+10) httpCount = f.capAtCurrentSlot(httpStart, httpCount) + if httpCount == 0 { + select { + case noRequestableRange <- struct{}{}: + default: + } + return + } httpBlocks, httpErr := fetchBlocksFromBeaconAPI(ctx, f.httpFallbackURL, httpStart, httpCount, f.beaconCfg) if httpErr == nil && len(httpBlocks) > 0 { log.Debug("[ForwardBeaconDownloader] P2P failed, fetched blocks from beacon API", @@ -320,6 +346,9 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) { }() case <-ctx.Done(): return + case <-noRequestableRange: + waitForForwardRequestRetry(ctx) + return case <-requestTimeout.C: // No blocks received in time — return to let stale detection run. return @@ -363,26 +392,14 @@ Process: return } - // For GLOAS blocks, fetch envelopes only for FULL blocks (whose payload was delivered). - // EMPTY blocks never have envelopes on the network, so requesting them causes a 30s stall. - // We determine FULL/EMPTY by comparing consecutive blocks' bids: - // block[i+1].bid.ParentBlockHash == block[i].bid.BlockHash → block[i] is FULL. - // - // We requested count+1 blocks so the extra lookahead block lets us determine the - // last batch block's FULL/EMPTY status accurately. Use all blocks for determination, - // then trim to `count` before processing. var envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope var nextGloasLookahead *cltypes.SignedBeaconBlock nextGloasCursor := nextSlotAfterRange(resp.rangeStart, resp.rangeCount) if hasGloasBlocks { - // Always keep at least 1 block as lookahead so the last processed - // block's FULL/EMPTY status is determined from the actual next block - // rather than guessed as EMPTY. Without this, a FULL block at the - // batch boundary has its envelope skipped, and the next batch's first - // block fails with ErrParentEnvelopePending. + // A Gloas block needs its successor to determine whether its envelope exists. processCount := min(int(count), len(processBlocks)-1) nextGloasLookahead = processBlocks[processCount] - if processCount+1 < len(processBlocks) { + if pid != "http-fallback" || processCount+1 < len(processBlocks) { nextGloasCursor = saturatingIncrement(nextGloasLookahead.Block.Slot) } fullRoots := determineFullGloasRoots(processBlocks, processCount) @@ -476,6 +493,15 @@ Process: } } +func waitForForwardRequestRetry(ctx context.Context) { + timer := time.NewTimer(forwardRequestRetryInterval) + defer timer.Stop() + select { + case <-ctx.Done(): + case <-timer.C: + } +} + func (f *ForwardBeaconDownloader) capAtCurrentSlot(start, count uint64) uint64 { if count == 0 || f.currentSlot == nil { return count @@ -742,19 +768,27 @@ func fetchBlocksFromBeaconAPI(ctx context.Context, baseURL string, startSlot, co client := &http.Client{Timeout: 10 * time.Second} sem := make(chan struct{}, 8) // limit concurrent requests var wg sync.WaitGroup + requestCtx, cancel := context.WithCancel(ctx) + defer cancel() for i := range count { slot := startSlot + i idx := i wg.Go(func() { - sem <- struct{}{} + select { + case sem <- struct{}{}: + case <-requestCtx.Done(): + results[idx].err = requestCtx.Err() + return + } defer func() { <-sem }() results[idx].slot = slot reqURL := fmt.Sprintf("%s/eth/v2/beacon/blocks/%d", baseURL, slot) - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + req, err := http.NewRequestWithContext(requestCtx, "GET", reqURL, nil) if err != nil { results[idx].err = err + cancel() return } req.Header.Set("Accept", "application/octet-stream") @@ -762,12 +796,13 @@ func fetchBlocksFromBeaconAPI(ctx context.Context, baseURL string, startSlot, co resp, err := client.Do(req) if err != nil { results[idx].err = fmt.Errorf("HTTP block fetch slot %d: %w", slot, err) + cancel() return } - body, readErr := io.ReadAll(resp.Body) - resp.Body.Close() + body, readErr := readBeaconAPIResponseBody(resp) if readErr != nil { results[idx].err = fmt.Errorf("HTTP block read slot %d: %w", slot, readErr) + cancel() return } if resp.StatusCode == http.StatusNotFound { @@ -775,13 +810,35 @@ func fetchBlocksFromBeaconAPI(ctx context.Context, baseURL string, startSlot, co } if resp.StatusCode != http.StatusOK { results[idx].err = fmt.Errorf("HTTP block fetch slot %d: status %d", slot, resp.StatusCode) + cancel() return } - version := httpConsensusVersion(resp.Header.Get("Eth-Consensus-Version")) + version, err := httpConsensusVersion(resp.Header.Get("Eth-Consensus-Version")) + if err != nil { + results[idx].err = fmt.Errorf("HTTP block version slot %d: %w", slot, err) + cancel() + return + } + if err := validateHTTPBlockVersion(beaconCfg, slot, version); err != nil { + results[idx].err = err + cancel() + return + } block := cltypes.NewSignedBeaconBlock(beaconCfg, version) - if err := block.DecodeSSZ(body, int(version)); err != nil { + if err := block.DecodeSSZStrict(body, int(version)); err != nil { results[idx].err = fmt.Errorf("HTTP block decode slot %d: %w", slot, err) + cancel() + return + } + if block.Block == nil { + results[idx].err = fmt.Errorf("HTTP block slot %d has no message", slot) + cancel() + return + } + if block.Block.Slot != slot { + results[idx].err = fmt.Errorf("HTTP block slot mismatch: requested %d, received %d", slot, block.Block.Slot) + cancel() return } results[idx].block = block @@ -801,28 +858,35 @@ func fetchBlocksFromBeaconAPI(ctx context.Context, baseURL string, startSlot, co return blocks, nil } -// httpConsensusVersion maps the Eth-Consensus-Version header to a StateVersion. -func httpConsensusVersion(header string) clparams.StateVersion { - switch strings.ToLower(header) { - case "phase0": - return clparams.Phase0Version - case "altair": - return clparams.AltairVersion - case "bellatrix": - return clparams.BellatrixVersion - case "capella": - return clparams.CapellaVersion - case "deneb": - return clparams.DenebVersion - case "electra": - return clparams.ElectraVersion - case "fulu": - return clparams.FuluVersion - case "gloas", "glamsterdam": - return clparams.GloasVersion - default: - return clparams.GloasVersion +func readBeaconAPIResponseBody(resp *http.Response) ([]byte, error) { + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, int64(clparams.MaxChunkSize)+1)) + if err != nil { + return nil, err } + if uint64(len(body)) > clparams.MaxChunkSize { + return nil, fmt.Errorf("response body exceeds %d bytes", clparams.MaxChunkSize) + } + return body, nil +} + +func httpConsensusVersion(header string) (clparams.StateVersion, error) { + header = strings.ToLower(strings.TrimSpace(header)) + if header == "" { + return 0, errors.New("missing Eth-Consensus-Version header") + } + return clparams.StringToClVersion(header) +} + +func validateHTTPBlockVersion(beaconCfg *clparams.BeaconChainConfig, slot uint64, version clparams.StateVersion) error { + if beaconCfg == nil || beaconCfg.SlotsPerEpoch == 0 { + return errors.New("invalid beacon chain config") + } + expected := beaconCfg.GetCurrentStateVersion(slot / beaconCfg.SlotsPerEpoch) + if version != expected { + return fmt.Errorf("HTTP block version mismatch at slot %d: expected %s, received %s", slot, expected, version) + } + return nil } // fetchEnvelopesFromBeaconAPI fetches execution payload envelopes from the beacon API @@ -886,7 +950,7 @@ func fetchEnvelopesFromBeaconAPI( sem <- struct{}{} defer func() { <-sem }() - reqURL := fmt.Sprintf("%s/eth/v1/beacon/execution_payload_envelope/%d", baseURL, slot) + reqURL := fmt.Sprintf("%s/eth/v1/beacon/execution_payload_envelope/0x%x", baseURL, root) req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return @@ -897,17 +961,23 @@ func fetchEnvelopesFromBeaconAPI( if err != nil { return } - body, err := io.ReadAll(resp.Body) - resp.Body.Close() + body, err := readBeaconAPIResponseBody(resp) if err != nil || resp.StatusCode != http.StatusOK { return } + version, err := httpConsensusVersion(resp.Header.Get("Eth-Consensus-Version")) + if err != nil || version != clparams.GloasVersion || validateHTTPBlockVersion(beaconCfg, slot, version) != nil { + return + } envelope := &cltypes.SignedExecutionPayloadEnvelope{ Message: cltypes.NewExecutionPayloadEnvelope(beaconCfg), } - if err := envelope.DecodeSSZ(body, int(clparams.GloasVersion)); err != nil { - log.Debug("[ForwardBeaconDownloader] HTTP envelope decode failed", "slot", slot, "err", err) + if err := envelope.DecodeSSZStrict(body, int(clparams.GloasVersion)); err != nil { + log.Debug("[ForwardBeaconDownloader] HTTP envelope decode failed", "root", common.Hash(root), "err", err) + return + } + if envelope.Message == nil || envelope.Message.BeaconBlockRoot != common.Hash(root) { return } results[idx] = envResult{hash: common.Hash(root), envelope: envelope} diff --git a/cl/phase1/network/beacon_downloader_test.go b/cl/phase1/network/beacon_downloader_test.go index 4b23031b2ca..be52458f765 100644 --- a/cl/phase1/network/beacon_downloader_test.go +++ b/cl/phase1/network/beacon_downloader_test.go @@ -1,6 +1,7 @@ package network import ( + "bytes" "context" "errors" "fmt" @@ -19,6 +20,18 @@ import ( "github.com/erigontech/erigon/common" ) +func gloasFromGenesisConfig() *clparams.BeaconChainConfig { + cfg := clparams.MainnetBeaconConfig + cfg.AltairForkEpoch = 0 + cfg.BellatrixForkEpoch = 0 + cfg.CapellaForkEpoch = 0 + cfg.DenebForkEpoch = 0 + cfg.ElectraForkEpoch = 0 + cfg.FuluForkEpoch = 0 + cfg.GloasForkEpoch = 0 + return &cfg +} + func linkBeaconBlocks(t *testing.T, blocks ...*cltypes.SignedBeaconBlock) { t.Helper() for i := 1; i < len(blocks); i++ { @@ -105,6 +118,118 @@ func TestShouldBanProcessPeer(t *testing.T) { require.False(t, shouldBanProcessPeer("http-fallback", processErr)) } +func TestFetchBlocksFromBeaconAPIRejectsMissingConsensusVersion(t *testing.T) { + block := makeGloasBlock(10, hash(0xaa), common.Hash{}) + encoded, err := block.EncodeSSZ(nil) + require.NoError(t, err) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(encoded) + })) + defer server.Close() + + blocks, err := fetchBlocksFromBeaconAPI(t.Context(), server.URL, 10, 1, gloasFromGenesisConfig()) + require.Error(t, err) + require.Empty(t, blocks) +} + +func TestFetchBlocksFromBeaconAPIRejectsWrongSlot(t *testing.T) { + block := makeGloasBlock(11, hash(0xaa), common.Hash{}) + encoded, err := block.EncodeSSZ(nil) + require.NoError(t, err) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encoded) + })) + defer server.Close() + + blocks, err := fetchBlocksFromBeaconAPI(t.Context(), server.URL, 10, 1, gloasFromGenesisConfig()) + require.Error(t, err) + require.Empty(t, blocks) +} + +func TestFetchBlocksFromBeaconAPIRejectsVersionOutsideRequestedSlot(t *testing.T) { + cfg := gloasFromGenesisConfig() + block := cltypes.NewSignedBeaconBlock(cfg, clparams.FuluVersion) + block.Block.Slot = 10 + block.Block.Body.SyncAggregate = cltypes.NewSyncAggregate() + encoded, err := block.EncodeSSZ(nil) + require.NoError(t, err) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Eth-Consensus-Version", "fulu") + _, _ = w.Write(encoded) + })) + defer server.Close() + + blocks, err := fetchBlocksFromBeaconAPI(t.Context(), server.URL, 10, 1, cfg) + require.ErrorContains(t, err, "version mismatch") + require.Empty(t, blocks) +} + +func TestFetchBlocksFromBeaconAPIRejectsTrailingSSZData(t *testing.T) { + block := makeGloasBlock(10, hash(0xaa), common.Hash{}) + encoded, err := block.EncodeSSZ(nil) + require.NoError(t, err) + encoded = append(encoded, 0) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encoded) + })) + defer server.Close() + + blocks, err := fetchBlocksFromBeaconAPI(t.Context(), server.URL, 10, 1, gloasFromGenesisConfig()) + require.ErrorContains(t, err, "decode") + require.Empty(t, blocks) +} + +func TestFetchBlocksFromBeaconAPIBoundsErrorBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write(bytes.Repeat([]byte{'x'}, int(clparams.MaxChunkSize)+1)) + })) + defer server.Close() + + _, err := fetchBlocksFromBeaconAPI(t.Context(), server.URL, 10, 1, gloasFromGenesisConfig()) + require.ErrorContains(t, err, "exceeds") +} + +func TestFetchEnvelopesFromBeaconAPIUsesRootAndRejectsIdentityMismatch(t *testing.T) { + cfg := gloasFromGenesisConfig() + block := makeGloasBlock(10, hash(0xaa), common.Hash{}) + blockRoot, err := block.Block.HashSSZ() + require.NoError(t, err) + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(cfg)} + envelope.Message.BeaconBlockRoot = common.HexToHash("0xdead") + encoded, err := envelope.EncodeSSZ(nil) + require.NoError(t, err) + requestedPath := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPath <- r.URL.Path + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encoded) + })) + defer server.Close() + + received := make(map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) + fetched := fetchEnvelopesFromBeaconAPI(t.Context(), server.URL, []*cltypes.SignedBeaconBlock{block}, [][32]byte{blockRoot}, received, cfg) + require.Zero(t, fetched) + require.Empty(t, received) + require.Equal(t, "/eth/v1/beacon/execution_payload_envelope/0x"+common.Hash(blockRoot).Hex()[2:], <-requestedPath) +} + +func TestForwardBeaconDownloaderHTTPFailureWithoutP2PReturnsSafely(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + downloader := NewForwardBeaconDownloader(t.Context(), nil, gloasFromGenesisConfig()) + downloader.SetHTTPFallbackURL(server.URL) + downloader.httpPreferred.Store(true) + downloader.SetHighestProcessedSlot(9) + downloader.SetCurrentSlotSampler(func() uint64 { return 10 }) + + require.NotPanics(t, func() { downloader.RequestMore(t.Context()) }) +} + func TestForwardBeaconDownloaderRetainsFrontierWhenHTTPFullEnvelopeIsMissing(t *testing.T) { first := makeGloasBlock(10, hash(0xaa), common.Hash{}) lookahead := makeGloasBlock(11, hash(0xbb), hash(0xaa)) @@ -127,7 +252,7 @@ func TestForwardBeaconDownloaderRetainsFrontierWhenHTTPFullEnvelopeIsMissing(t * })) defer server.Close() - downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig) + downloader := NewForwardBeaconDownloader(context.Background(), nil, gloasFromGenesisConfig()) downloader.SetHighestProcessedSlot(9) downloader.SetHTTPFallbackURL(server.URL) downloader.httpPreferred.Store(true) @@ -160,7 +285,7 @@ func TestForwardBeaconDownloaderRetainsSingleHTTPGloasBlockUntilLookahead(t *tes })) defer server.Close() - downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig) + downloader := NewForwardBeaconDownloader(context.Background(), nil, gloasFromGenesisConfig()) downloader.SetHighestProcessedSlot(9) downloader.SetHTTPFallbackURL(server.URL) downloader.httpPreferred.Store(true) @@ -228,9 +353,9 @@ func TestForwardBeaconDownloaderScansPastGloasLookaheadGap(t *testing.T) { downloader.RequestMore(t.Context()) }() emptyRequest := <-requests - require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier + 31, Count: 33}, emptyRequest) + require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier + 2, Count: 33}, emptyRequest) farRequest := <-requests - require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier + 64, Count: 3}, farRequest) + require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier + 35, Count: 32}, farRequest) require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) close(allowSuccessor) <-done @@ -290,6 +415,71 @@ func TestForwardBeaconDownloaderRechecksNewSlotAfterNearHeadLookahead(t *testing require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot()) } +func TestForwardBeaconDownloaderPacesUnavailableNextSlot(t *testing.T) { + const frontier = uint64(100) + ctx, cancel := context.WithCancel(t.Context()) + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(context.Context, uint64, uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + t.Fatal("requested a range whose first slot does not exist") + return nil, "", nil + }, + } + downloader.SetHighestProcessedSlot(frontier) + downloader.gloasLookahead = makeGloasBlock(frontier+1, hash(0xaa), common.Hash{}) + downloader.gloasNextUnscanned = frontier + 2 + downloader.SetCurrentSlotSampler(func() uint64 { return frontier + 1 }) + + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(ctx) + }() + select { + case <-done: + t.Fatal("unavailable next slot returned without pacing") + case <-time.After(50 * time.Millisecond): + } + cancel() + <-done +} + +func TestForwardBeaconDownloaderDoesNotSendZeroCountAfterSlotSampleMovesBack(t *testing.T) { + const frontier = uint64(100) + var samples atomic.Int32 + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 1) + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(_ context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + return nil, "peer-a", nil + }, + } + downloader.SetHighestProcessedSlot(frontier) + downloader.SetCurrentSlotSampler(func() uint64 { + if samples.Add(1) == 1 { + return frontier + } + return frontier - 3 + }) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(ctx) + }() + select { + case request := <-requests: + cancel() + <-done + require.NotZero(t, request.Count) + case <-time.After(400 * time.Millisecond): + cancel() + <-done + } +} + func TestForwardBeaconDownloaderHTTPScansPastGloasLookaheadGap(t *testing.T) { const frontier = uint64(100) first := makeGloasBlock(frontier+1, hash(0xaa), common.Hash{}) @@ -317,7 +507,7 @@ func TestForwardBeaconDownloaderHTTPScansPastGloasLookaheadGap(t *testing.T) { })) defer server.Close() - downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig) + downloader := NewForwardBeaconDownloader(context.Background(), nil, gloasFromGenesisConfig()) downloader.SetCurrentSlotSampler(func() uint64 { return frontier + 45 }) downloader.SetHighestProcessedSlot(frontier) downloader.SetHTTPFallbackURL(server.URL) @@ -392,7 +582,7 @@ func TestForwardBeaconDownloaderRetainsGloasScanAcrossDuplicateAndCancellation(t downloader.RequestMore(t.Context()) require.Equal(t, frontier-2, (<-requests).StartSlot) downloader.RequestMore(t.Context()) - require.Equal(t, frontier+31, (<-requests).StartSlot) + require.Equal(t, frontier+2, (<-requests).StartSlot) require.Empty(t, processedSlots) require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) @@ -405,7 +595,7 @@ func TestForwardBeaconDownloaderRetainsGloasScanAcrossDuplicateAndCancellation(t canceledRequest := <-requests cancel() <-done - require.Equal(t, frontier+64, canceledRequest.StartSlot) + require.Equal(t, frontier+2, canceledRequest.StartSlot) require.Equal(t, frontier, downloader.GetHighestProcessedSlot()) downloader.RequestMore(t.Context()) @@ -502,6 +692,48 @@ func TestForwardBeaconDownloaderRetainsUnprocessedGloasSuffix(t *testing.T) { require.Equal(t, frontier+3, downloader.GetHighestProcessedSlot()) } +func TestForwardBeaconDownloaderPartialGloasRangeResumesAfterReturnedLookahead(t *testing.T) { + const frontier = uint64(100) + first := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{}) + lookahead := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1)) + linkBeaconBlocks(t, first, lookahead) + + requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2) + downloader := &ForwardBeaconDownloader{ + beaconCfg: &clparams.MainnetBeaconConfig, + requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) { + requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count} + if start == frontier-2 { + return []*cltypes.SignedBeaconBlock{first, lookahead}, "peer-a", nil + } + <-ctx.Done() + return nil, "peer-b", ctx.Err() + }, + } + downloader.SetHighestProcessedSlot(frontier) + downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) { + if len(blocks) > 0 { + return blocks[len(blocks)-1].Block.Slot, nil + } + return highest, nil + }) + + downloader.RequestMore(t.Context()) + require.Equal(t, frontier-2, (<-requests).StartSlot) + require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot()) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + downloader.RequestMore(ctx) + }() + next := <-requests + cancel() + <-done + require.Equal(t, frontier+3, next.StartSlot) +} + func TestForwardBeaconDownloaderMixedOwnerProcessErrorRestartsOverlap(t *testing.T) { const frontier = uint64(100) cached := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{}) @@ -678,7 +910,7 @@ func TestForwardBeaconDownloaderOverlapOnlyProgressRetainsLookahead(t *testing.T next := <-requests cancel() <-done - require.Equal(t, frontier+31, next.StartSlot) + require.Equal(t, frontier+2, next.StartSlot) } func TestInvalidGloasResponseAction(t *testing.T) { @@ -801,7 +1033,7 @@ func TestForwardBeaconDownloaderRejectsDisconnectedGloasResponseSuffix(t *testin retry := <-requests cancel() <-done - require.Equal(t, frontier+31, retry.StartSlot) + require.Equal(t, frontier+2, retry.StartSlot) } func TestForwardBeaconDownloaderDisconnectedHTTPResponseFallsBackToP2P(t *testing.T) { @@ -827,7 +1059,7 @@ func TestForwardBeaconDownloaderDisconnectedHTTPResponseFallsBackToP2P(t *testin })) defer server.Close() - downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig) + downloader := NewForwardBeaconDownloader(context.Background(), nil, gloasFromGenesisConfig()) downloader.SetHighestProcessedSlot(frontier) downloader.SetHTTPFallbackURL(server.URL) downloader.httpPreferred.Store(true) diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index d6cd9634935..d9654e82b3a 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -45,6 +45,7 @@ import ( ) var ErrInvalidSignature = errors.New("invalid signature") +var ErrPublishedBlockJobExpired = errors.New("published block integration expired") type proposerIndexAndSlot struct { proposerIndex uint64 @@ -53,8 +54,87 @@ type proposerIndexAndSlot struct { type blockJob struct { block *cltypes.SignedBeaconBlock - store func(context.Context) error creationTime time.Time + + mu sync.Mutex + store func(context.Context) error + storeGeneration uint64 + completedGeneration uint64 + terminal bool + running bool + attempt *blockJobAttempt + lastAttempt *blockJobAttempt +} + +type blockJobAttempt struct { + done chan struct{} + generation uint64 + err error +} + +type publishedBlockJobHandle struct { + mu sync.Mutex + job *blockJob + generation uint64 + observed *blockJobAttempt +} + +func (h *publishedBlockJobHandle) Wait(ctx context.Context) error { + h.mu.Lock() + defer h.mu.Unlock() + for { + h.job.mu.Lock() + if last := h.job.lastAttempt; last != nil && last != h.observed && last.generation >= h.generation { + h.observed = last + err := last.err + h.job.mu.Unlock() + return err + } + if h.job.terminal && h.job.completedGeneration >= h.generation { + err := h.job.lastAttempt.err + h.job.mu.Unlock() + return err + } + attempt := h.job.attempt + h.job.mu.Unlock() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-attempt.done: + if attempt.generation >= h.generation { + h.observed = attempt + if attempt.err != nil { + return attempt.err + } + } + } + } +} + +func newBlockJob(block *cltypes.SignedBeaconBlock, store func(context.Context) error) *blockJob { + generation := uint64(0) + if store != nil { + generation = 1 + } + return &blockJob{ + block: block, + store: store, + storeGeneration: generation, + creationTime: time.Now(), + attempt: &blockJobAttempt{done: make(chan struct{})}, + } +} + +func newFailedBlockJob(block *cltypes.SignedBeaconBlock, store func(context.Context) error, err error) *blockJob { + job := newBlockJob(block, store) + job.attempt.err = err + job.attempt.generation = job.storeGeneration + job.lastAttempt = job.attempt + job.completedGeneration = job.storeGeneration + job.terminal = true + close(job.attempt.done) + return job } type blockReservation struct { @@ -570,11 +650,15 @@ func (b *blockService) ScheduleBlockForLaterProcessing(block *cltypes.SignedBeac b.scheduleBlockForLaterProcessing(block, nil) } -func (b *blockService) SchedulePublishedBlockForLaterProcessing(block *cltypes.SignedBeaconBlock, store func(context.Context) error) { - b.scheduleBlockForLaterProcessing(block, store) +func (b *blockService) SchedulePublishedBlockForLaterProcessing(block *cltypes.SignedBeaconBlock, store func(context.Context) error) PublishedBlockJob { + job := b.scheduleBlockForLaterProcessing(block, store) + job.mu.Lock() + handle := &publishedBlockJobHandle{job: job, generation: job.storeGeneration} + job.mu.Unlock() + return handle } -func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeaconBlock, store func(context.Context) error) { +func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeaconBlock, store func(context.Context) error) *blockJob { // [Modified in Gloas:EIP7732] ExecutionPayload is not in block.body for GLOAS var blockNum uint64 if block.Block.Body.ExecutionPayload != nil { @@ -584,27 +668,31 @@ func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeac blockRoot, err := block.Block.HashSSZ() if err != nil { log.Debug("Failed to hash block", "block", block, "error", err) - return + return newFailedBlockJob(block, store, err) } - job := &blockJob{ - block: block, - store: store, - creationTime: time.Now(), + job := newBlockJob(block, store) + existingValue, loaded := b.blocksScheduledForLaterExecution.LoadOrStore(blockRoot, job) + if !loaded { + return job } - for { - existingValue, loaded := b.blocksScheduledForLaterExecution.LoadOrStore(blockRoot, job) - if !loaded { - return - } - existing := existingValue.(*blockJob) - if store == nil || (existing.store != nil && !job.creationTime.After(existing.creationTime)) { - return - } - if b.blocksScheduledForLaterExecution.CompareAndSwap(blockRoot, existing, job) { - return - } + existing := existingValue.(*blockJob) + if store == nil { + return existing } + existing.mu.Lock() + defer existing.mu.Unlock() + if !job.creationTime.After(existing.creationTime) { + return existing + } + existing.store = store + existing.storeGeneration++ + existing.creationTime = time.Now() + if existing.terminal { + existing.terminal = false + existing.attempt = &blockJobAttempt{done: make(chan struct{})} + } + return existing } // processAndStoreBlock processes and stores a block @@ -679,17 +767,57 @@ func (b *blockService) loop(ctx context.Context) { } func (b *blockService) processScheduledBlock(ctx context.Context, key [32]byte, job *blockJob, now time.Time) { + job.mu.Lock() + if job.running { + job.mu.Unlock() + return + } if now.Sub(job.creationTime) > blockJobExpiry { + if !job.terminal { + job.attempt.err = ErrPublishedBlockJobExpired + job.attempt.generation = job.storeGeneration + job.lastAttempt = job.attempt + job.completedGeneration = job.storeGeneration + job.terminal = true + close(job.attempt.done) + } + job.mu.Unlock() b.blocksScheduledForLaterExecution.CompareAndDelete(key, job) return } + if job.terminal { + job.mu.Unlock() + return + } + job.running = true store := job.store + generation := job.storeGeneration + attempt := job.attempt + job.mu.Unlock() if store == nil { store = func(ctx context.Context) error { return b.processAndStoreBlock(ctx, job.block) } } - if err := store(ctx); err != nil { + err := store(ctx) + job.mu.Lock() + job.running = false + attempt.err = err + attempt.generation = generation + close(attempt.done) + job.lastAttempt = attempt + latest := generation == job.storeGeneration + terminal := latest && (err == nil || errors.Is(err, forkchoice.ErrBlockInvalid)) + if terminal { + job.completedGeneration = generation + job.terminal = true + } else { + job.attempt = &blockJobAttempt{done: make(chan struct{})} + } + job.mu.Unlock() + if terminal { + b.blocksScheduledForLaterExecution.CompareAndDelete(key, job) + } + if err != nil { log.Trace("Failed to process and store block", "block", job.block, "error", err) return } - b.blocksScheduledForLaterExecution.CompareAndDelete(key, job) } diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index 734e34a0b29..95492984b9c 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -21,6 +21,7 @@ import ( "context" "encoding/binary" "errors" + "fmt" "sync" "testing" "time" @@ -472,7 +473,7 @@ func TestPublishedBlockJobRetainsFullStoreUntilSuccess(t *testing.T) { attempts := 0 storedSidecars := false imported := false - service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + handle := service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { attempts++ if attempts == 1 { return errors.New("sidecar storage unavailable") @@ -493,6 +494,9 @@ func TestPublishedBlockJobRetainsFullStoreUntilSuccess(t *testing.T) { service.processScheduledBlock(t.Context(), root, job, job.creationTime) _, ok = service.blocksScheduledForLaterExecution.Load(root) require.False(t, ok) + require.True(t, job.terminal) + require.NoError(t, handle.Wait(t.Context())) + require.NoError(t, handle.Wait(t.Context())) require.True(t, storedSidecars) require.True(t, imported) require.NoError(t, db.View(t.Context(), func(tx kv.Tx) error { @@ -562,12 +566,15 @@ func TestPublishedBlockUpgradeSurvivesStaleBlockOnlyWorker(t *testing.T) { staleValue, ok := service.blocksScheduledForLaterExecution.Load(root) require.True(t, ok) staleJob := staleValue.(*blockJob) - service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil }) + fullStoreCalls := 0 + service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + fullStoreCalls++ + return nil + }) service.processScheduledBlock(t.Context(), root, staleJob, staleJob.creationTime) - currentValue, ok := service.blocksScheduledForLaterExecution.Load(root) - require.True(t, ok) - require.NotSame(t, staleJob, currentValue) - require.NotNil(t, currentValue.(*blockJob).store) + _, ok = service.blocksScheduledForLaterExecution.Load(root) + require.False(t, ok) + require.Equal(t, 1, fullStoreCalls) } func TestPublishedBlockRefreshSurvivesStaleFullStoreWorker(t *testing.T) { @@ -580,18 +587,15 @@ func TestPublishedBlockRefreshSurvivesStaleFullStoreWorker(t *testing.T) { }) staleValue, ok := service.blocksScheduledForLaterExecution.Load(root) require.True(t, ok) - staleJob := staleValue.(*blockJob) + stableJob := staleValue.(*blockJob) freshStoreCalls := 0 service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { freshStoreCalls++ return nil }) - staleJob.creationTime = time.Now().Add(-blockJobExpiry - time.Second) - service.processScheduledBlock(t.Context(), root, staleJob, time.Now()) - freshValue, ok := service.blocksScheduledForLaterExecution.Load(root) require.True(t, ok) - require.NotSame(t, staleJob, freshValue) + require.Same(t, stableJob, freshValue) service.processScheduledBlock(t.Context(), root, freshValue.(*blockJob), time.Now()) require.Equal(t, 1, freshStoreCalls) _, ok = service.blocksScheduledForLaterExecution.Load(root) @@ -926,3 +930,175 @@ func TestBlockServiceDecodeGossipMessageStrictPreGloasCompatibility(t *testing.T _, err = service.DecodeGossipMessage("peer", encoded, clparams.DenebVersion) require.NoError(t, err) } + +func TestPublishedBlockJobUpgradeKeepsWaiterOnRequiredStoreGeneration(t *testing.T) { + service := &blockService{} + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + firstStarted := make(chan struct{}) + firstRelease := make(chan struct{}) + service.scheduleBlockForLaterProcessing(block, func(context.Context) error { + close(firstStarted) + <-firstRelease + return nil + }) + firstDone := make(chan struct{}) + go func() { + service.processScheduledBlock(context.Background(), root, serviceJob(t, service, root), time.Now()) + close(firstDone) + }() + <-firstStarted + secondCalls := 0 + handle := service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + secondCalls++ + return nil + }) + waitDone := make(chan error, 1) + go func() { waitDone <- handle.Wait(t.Context()) }() + close(firstRelease) + <-firstDone + _, scheduled := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, scheduled) + select { + case err := <-waitDone: + t.Fatalf("waiter completed for superseded store generation: %v", err) + default: + } + service.processScheduledBlock(context.Background(), root, serviceJob(t, service, root), time.Now()) + require.NoError(t, <-waitDone) + require.Equal(t, 1, secondCalls) +} + +func TestPublishedBlockJobTransientFailureReturnsToWaiterAndRemainsRetryable(t *testing.T) { + service := &blockService{} + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + transient := errors.New("database unavailable") + calls := 0 + handle := service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + calls++ + if calls == 1 { + return transient + } + return nil + }) + firstWaitDone := make(chan error, 1) + go func() { firstWaitDone <- handle.Wait(t.Context()) }() + waiter := handle.(*publishedBlockJobHandle) + require.Eventually(t, func() bool { + if waiter.mu.TryLock() { + waiter.mu.Unlock() + return false + } + return true + }, time.Second, time.Millisecond) + service.processScheduledBlock(context.Background(), root, serviceJob(t, service, root), time.Now()) + require.ErrorIs(t, <-firstWaitDone, transient) + waitDone := make(chan error, 1) + go func() { waitDone <- handle.Wait(t.Context()) }() + select { + case err := <-waitDone: + t.Fatalf("waiter replayed a previously observed transient failure: %v", err) + default: + } + service.processScheduledBlock(context.Background(), root, serviceJob(t, service, root), time.Now()) + require.NoError(t, <-waitDone) + require.Equal(t, 2, calls) +} + +func TestPublishedBlockJobWaitConsumesAttemptCompletedWhileWaiting(t *testing.T) { + transient := errors.New("database unavailable") + job := newBlockJob(cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version), func(context.Context) error { + return transient + }) + handle := &publishedBlockJobHandle{job: job, generation: job.storeGeneration} + attempt := job.attempt + attempt.err = transient + attempt.generation = job.storeGeneration + close(attempt.done) + require.ErrorIs(t, handle.Wait(t.Context()), transient) + + job.mu.Lock() + job.lastAttempt = attempt + job.attempt = &blockJobAttempt{done: make(chan struct{})} + job.mu.Unlock() + waitCtx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, handle.Wait(waitCtx), context.Canceled) +} + +func TestPublishedBlockJobRequestCancellationDoesNotCancelIntegration(t *testing.T) { + service := &blockService{} + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + started := make(chan struct{}) + release := make(chan struct{}) + handle := service.SchedulePublishedBlockForLaterProcessing(block, func(ctx context.Context) error { + close(started) + select { + case <-ctx.Done(): + return ctx.Err() + case <-release: + return nil + } + }) + processDone := make(chan struct{}) + go func() { + service.processScheduledBlock(context.Background(), root, serviceJob(t, service, root), time.Now()) + close(processDone) + }() + <-started + waitCtx, cancel := context.WithCancel(context.Background()) + cancel() + require.ErrorIs(t, handle.Wait(waitCtx), context.Canceled) + close(release) + <-processDone + require.NoError(t, handle.Wait(t.Context())) +} + +func TestPublishedBlockJobPermanentFailureIsTerminal(t *testing.T) { + service := &blockService{} + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + calls := 0 + permanent := fmt.Errorf("%w: execution payload is invalid", forkchoice.ErrBlockInvalid) + handle := service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + calls++ + return permanent + }) + job := serviceJob(t, service, root) + service.processScheduledBlock(context.Background(), root, job, time.Now()) + _, scheduled := service.blocksScheduledForLaterExecution.Load(root) + require.False(t, scheduled) + require.ErrorIs(t, handle.Wait(t.Context()), forkchoice.ErrBlockInvalid) + require.ErrorIs(t, handle.Wait(t.Context()), forkchoice.ErrBlockInvalid) + service.processScheduledBlock(context.Background(), root, job, time.Now()) + require.Equal(t, 1, calls) +} + +func TestPublishedBlockJobHashFailureWaitIsReplayable(t *testing.T) { + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + hashErr := errors.New("hash failure") + service := &blockService{} + job := newFailedBlockJob(block, func(context.Context) error { return nil }, hashErr) + handle := &publishedBlockJobHandle{job: job, generation: job.storeGeneration} + require.EqualError(t, handle.Wait(t.Context()), hashErr.Error()) + require.EqualError(t, handle.Wait(t.Context()), hashErr.Error()) + count := 0 + service.blocksScheduledForLaterExecution.Range(func(_, _ any) bool { + count++ + return true + }) + require.Zero(t, count) +} + +func serviceJob(t *testing.T, service *blockService, root [32]byte) *blockJob { + t.Helper() + job, ok := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + return job.(*blockJob) +} diff --git a/cl/phase1/network/services/execution_payload_bid_service.go b/cl/phase1/network/services/execution_payload_bid_service.go index 1e591ffb2dd..3e446021979 100644 --- a/cl/phase1/network/services/execution_payload_bid_service.go +++ b/cl/phase1/network/services/execution_payload_bid_service.go @@ -41,10 +41,8 @@ import ( ) type seenBidKey struct { - builderIndex uint64 - slot uint64 - parentBlockHash common.Hash - parentBlockRoot common.Hash + builderIndex uint64 + slot uint64 } type seenBidStore struct { @@ -84,10 +82,8 @@ func (s *seenBidStore) PruneExpired(clock eth_clock.EthereumClock, beaconCfg *cl func newSeenBidKey(bid *cltypes.ExecutionPayloadBid) seenBidKey { return seenBidKey{ - builderIndex: bid.BuilderIndex, - slot: bid.Slot, - parentBlockHash: bid.ParentBlockHash, - parentBlockRoot: bid.ParentBlockRoot, + builderIndex: bid.BuilderIndex, + slot: bid.Slot, } } diff --git a/cl/phase1/network/services/execution_payload_bid_service_test.go b/cl/phase1/network/services/execution_payload_bid_service_test.go index a47e92a149c..c549e805083 100644 --- a/cl/phase1/network/services/execution_payload_bid_service_test.go +++ b/cl/phase1/network/services/execution_payload_bid_service_test.go @@ -637,10 +637,13 @@ func TestExecutionPayloadBidServiceUsesCoherentHeadNodeSnapshot(t *testing.T) { require.True(t, compatible) } -func TestSeenBidKeyIncludesCompatibleParentTuple(t *testing.T) { +func TestSeenBidKeyIsBuilderAndSlotOnly(t *testing.T) { bid1 := newTestSignedExecutionPayloadBid(100, 1, 1000).Message bid2 := newTestSignedExecutionPayloadBid(100, 1, 1001).Message bid2.ParentBlockRoot = common.HexToHash("0xdddd") + require.Equal(t, newSeenBidKey(bid1), newSeenBidKey(bid2)) + + bid2.BuilderIndex++ require.NotEqual(t, newSeenBidKey(bid1), newSeenBidKey(bid2)) } @@ -972,7 +975,7 @@ func TestExecutionPayloadBidServiceRejectsLowerBidBeforeStateFetch(t *testing.T) require.Zero(t, service.validationStateCache.Len()) } -func TestExecutionPayloadBidServiceSameBuilderDistinctCompatibleParents(t *testing.T) { +func TestExecutionPayloadBidServiceRejectsSecondBidFromBuilderAtSameSlot(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -1004,17 +1007,16 @@ func TestExecutionPayloadBidServiceSameBuilderDistinctCompatibleParents(t *testi ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) err = service.ProcessMessage(context.Background(), nil, msg2) - require.NoError(t, err) + require.ErrorIs(t, err, ErrIgnore) + require.Contains(t, err.Error(), "already seen bid") - // Both should have their own highest bid bidKey1 := pool.HighestBidKey{Slot: 100, ParentBlockHash: parentHash1, ParentBlockRoot: parentRoot1} bidKey2 := pool.HighestBidKey{Slot: 100, ParentBlockHash: parentHash2, ParentBlockRoot: parentRoot2} stored1, found1 := epbsPool.HighestBids.Get(bidKey1) - stored2, found2 := epbsPool.HighestBids.Get(bidKey2) + _, found2 := epbsPool.HighestBids.Get(bidKey2) require.True(t, found1) - require.True(t, found2) + require.False(t, found2) require.Equal(t, uint64(1000), stored1.Message.Value) - require.Equal(t, uint64(500), stored2.Message.Value) } func TestExecutionPayloadBidServiceSuccess(t *testing.T) { diff --git a/cl/phase1/network/services/execution_payload_service.go b/cl/phase1/network/services/execution_payload_service.go index bb7740bf411..b7568fc1074 100644 --- a/cl/phase1/network/services/execution_payload_service.go +++ b/cl/phase1/network/services/execution_payload_service.go @@ -141,6 +141,13 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64, log.Trace("Received execution payload via gossip", "beaconBlockRoot", beaconBlockRoot, "builderIndex", builderIndex) + if envelope.Payload == nil { + return errors.New("nil execution payload") + } + finalizedSlot := s.forkchoiceStore.FinalizedCheckpoint().Epoch * s.beaconCfg.SlotsPerEpoch + if envelope.Payload.SlotNumber < finalizedSlot { + return fmt.Errorf("%w: envelope slot %d < finalized slot %d", ErrIgnore, envelope.Payload.SlotNumber, finalizedSlot) + } // [IGNORE] The envelope's block root has been seen (via gossip or non-gossip sources) // A client MAY queue payload for processing once the block is retrieved. @@ -169,15 +176,6 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64, return fmt.Errorf("%w: already seen envelope for block %v from builder %d", ErrIgnore, beaconBlockRoot, builderIndex) } - // [IGNORE] The envelope is from a slot greater than or equal to the latest finalized slot - finalizedSlot := s.forkchoiceStore.FinalizedCheckpoint().Epoch * s.beaconCfg.SlotsPerEpoch - if envelope.Payload == nil { - return errors.New("nil execution payload") - } - if envelope.Payload.SlotNumber < finalizedSlot { - return fmt.Errorf("%w: envelope slot %d < finalized slot %d", ErrIgnore, envelope.Payload.SlotNumber, finalizedSlot) - } - // Process the execution payload through forkchoice // Note: bid matching and signature verification are done in OnExecutionPayload.validateEnvelopeAgainstBlock if err := s.forkchoiceStore.OnExecutionPayload(ctx, signedEnvelope, true, true); err != nil { @@ -274,9 +272,7 @@ func (s *executionPayloadService) emitFullHeadUpdate(block *cltypes.SignedBeacon }) } -// queuePendingEnvelope adds an envelope to the pending queue for later processing func (s *executionPayloadService) queuePendingEnvelope(blockRoot common.Hash, envelope *cltypes.SignedExecutionPayloadEnvelope) (bool, error) { - // Compute envelope hash to allow multiple candidates per block envelopeHash, err := envelope.HashSSZ() if err != nil { return false, fmt.Errorf("failed to hash envelope for pending queue: %w", err) diff --git a/cl/phase1/network/services/execution_payload_service_test.go b/cl/phase1/network/services/execution_payload_service_test.go index 1949dda86d8..6c7ac3c79d6 100644 --- a/cl/phase1/network/services/execution_payload_service_test.go +++ b/cl/phase1/network/services/execution_payload_service_test.go @@ -355,6 +355,19 @@ func TestExecutionPayloadServiceSlotBelowFinalized(t *testing.T) { require.Contains(t, err.Error(), "envelope slot 50 < finalized slot 64") } +func TestExecutionPayloadServiceRejectsFinalizedUnknownBlockBeforeQueue(t *testing.T) { + service, fcu := setupExecutionPayloadService(t) + fcu.FinalizedCheckpointVal = solid.Checkpoint{Epoch: 2} + envelope := newTestSignedEnvelope(63, common.HexToHash("0x1234"), 1) + + err := service.ProcessMessage(context.Background(), nil, envelope) + require.ErrorIs(t, err, ErrIgnore) + require.Contains(t, err.Error(), "envelope slot 63 < finalized slot 64") + impl := service.(*executionPayloadService) + require.Zero(t, impl.pendingCount.Load()) + require.Zero(t, impl.pendingBytes.Load()) +} + func TestExecutionPayloadServiceUsesFinalizedEpochStartBoundary(t *testing.T) { for _, tc := range []struct { name string diff --git a/cl/phase1/network/services/mock_services/block_service_mock.go b/cl/phase1/network/services/mock_services/block_service_mock.go index 2f3d16572b1..e3d31f31439 100644 --- a/cl/phase1/network/services/mock_services/block_service_mock.go +++ b/cl/phase1/network/services/mock_services/block_service_mock.go @@ -15,6 +15,7 @@ import ( clparams "github.com/erigontech/erigon/cl/clparams" cltypes "github.com/erigontech/erigon/cl/cltypes" + services "github.com/erigontech/erigon/cl/phase1/network/services" peer "github.com/libp2p/go-libp2p/core/peer" gomock "go.uber.org/mock/gomock" ) @@ -267,9 +268,11 @@ func (c *MockBlockServiceScheduleBlockForLaterProcessingCall) DoAndReturn(f func } // SchedulePublishedBlockForLaterProcessing mocks base method. -func (m *MockBlockService) SchedulePublishedBlockForLaterProcessing(arg0 *cltypes.SignedBeaconBlock, arg1 func(context.Context) error) { +func (m *MockBlockService) SchedulePublishedBlockForLaterProcessing(arg0 *cltypes.SignedBeaconBlock, arg1 func(context.Context) error) services.PublishedBlockJob { m.ctrl.T.Helper() - m.ctrl.Call(m, "SchedulePublishedBlockForLaterProcessing", arg0, arg1) + ret := m.ctrl.Call(m, "SchedulePublishedBlockForLaterProcessing", arg0, arg1) + ret0, _ := ret[0].(services.PublishedBlockJob) + return ret0 } // SchedulePublishedBlockForLaterProcessing indicates an expected call of SchedulePublishedBlockForLaterProcessing. @@ -285,19 +288,19 @@ type MockBlockServiceSchedulePublishedBlockForLaterProcessingCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall) Return() *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall { - c.Call = c.Call.Return() +func (c *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall) Return(arg0 services.PublishedBlockJob) *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall { + c.Call = c.Call.Return(arg0) return c } // Do rewrite *gomock.Call.Do -func (c *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall) Do(f func(*cltypes.SignedBeaconBlock, func(context.Context) error)) *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall { +func (c *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall) Do(f func(*cltypes.SignedBeaconBlock, func(context.Context) error) services.PublishedBlockJob) *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall) DoAndReturn(f func(*cltypes.SignedBeaconBlock, func(context.Context) error)) *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall { +func (c *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall) DoAndReturn(f func(*cltypes.SignedBeaconBlock, func(context.Context) error) services.PublishedBlockJob) *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/cl/phase1/network/services/types.go b/cl/phase1/network/services/types.go index 43b498cce59..abb6b05ce55 100644 --- a/cl/phase1/network/services/types.go +++ b/cl/phase1/network/services/types.go @@ -7,6 +7,10 @@ import ( serviceinterface "github.com/erigontech/erigon/cl/phase1/network/services/service_interface" ) +type PublishedBlockJob interface { + Wait(context.Context) error +} + //go:generate mockgen -typed=true -destination=./mock_services/block_service_mock.go -package=mock_services . BlockService type BlockService interface { serviceinterface.Service[*cltypes.SignedBeaconBlock] @@ -14,7 +18,7 @@ type BlockService interface { CommitGossipReservation(*cltypes.SignedBeaconBlock) ReleaseGossipReservation(*cltypes.SignedBeaconBlock) ScheduleBlockForLaterProcessing(*cltypes.SignedBeaconBlock) - SchedulePublishedBlockForLaterProcessing(*cltypes.SignedBeaconBlock, func(context.Context) error) + SchedulePublishedBlockForLaterProcessing(*cltypes.SignedBeaconBlock, func(context.Context) error) PublishedBlockJob } //go:generate mockgen -typed=true -destination=./mock_services/blob_sidecars_service_mock.go -package=mock_services . BlobSidecarsService From a2cb2f066cb414e77c3c0ac81c9e0b5a05b07065 Mon Sep 17 00:00:00 2001 From: kewei Date: Fri, 28 Aug 2026 04:33:55 +0800 Subject: [PATCH 41/43] cl: close Gloas retry lifecycle gaps --- cl/beacon/handler/epbs.go | 8 +- cl/beacon/handler/epbs_test.go | 10 +- .../network/backward_beacon_downloader.go | 129 ++++++--- .../backward_beacon_downloader_test.go | 272 +++++++++++++++++- cl/phase1/network/beacon_downloader.go | 45 ++- cl/phase1/network/beacon_downloader_test.go | 6 +- cl/phase1/network/services/block_service.go | 94 ++++-- .../network/services/block_service_test.go | 149 ++++++++++ .../services/execution_payload_bid_service.go | 12 +- .../execution_payload_bid_service_test.go | 19 +- 10 files changed, 646 insertions(+), 98 deletions(-) diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go index 816d94dfbae..08f4b74b816 100644 --- a/cl/beacon/handler/epbs.go +++ b/cl/beacon/handler/epbs.go @@ -894,6 +894,7 @@ func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit } gossipValidated := false emitGossipEvent := false + emitIntegrationEvents := false if err := a.forkchoiceStore.OnExecutionPayload(r.Context(), signedEnvelope, canonical, true); err != nil { if canonical && !blobDataIncluded && errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) { beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w) @@ -915,7 +916,9 @@ func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit break } a.logger.Debug("[Beacon REST] OnExecutionPayload queued or ignored", "err", err) - status = http.StatusAccepted + if !contentsIntegrationFailed { + status = http.StatusOK + } gossipValidated = true case errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable): a.logger.Debug("[Beacon REST] OnExecutionPayload queued or ignored", "err", err) @@ -936,6 +939,7 @@ func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit } else { gossipValidated = true emitGossipEvent = true + emitIntegrationEvents = true } if gossipValidated && (canonical || a.sentinel != nil) && validation == BlockPublishingValidationConsensusAndEquivocation { block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot) @@ -953,7 +957,7 @@ func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit }) } } - if status == http.StatusOK && a.emitters != nil { + if status == http.StatusOK && emitIntegrationEvents && a.emitters != nil { block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot) if ok && block != nil && block.Block != nil && signedEnvelope.Message.Payload != nil { a.emitters.Operation().SendExecutionPayload(&beaconevents.ExecutionPayloadData{ diff --git a/cl/beacon/handler/epbs_test.go b/cl/beacon/handler/epbs_test.go index 405276545d0..898e0e2879f 100644 --- a/cl/beacon/handler/epbs_test.go +++ b/cl/beacon/handler/epbs_test.go @@ -803,7 +803,7 @@ func TestPostExecutionPayloadEnvelopesReturnsErrorWhenGossipPublishFails(t *test request.Header.Set("Eth-Consensus-Version", "gloas") recorder = httptest.NewRecorder() handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request) - require.Equal(t, http.StatusAccepted, recorder.Code, recorder.Body.String()) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) select { case event := <-events: t.Fatalf("retry emitted duplicate event %s", event.Event) @@ -883,7 +883,8 @@ func TestPostExecutionPayloadEnvelopeConcurrentIdenticalReconcilesPersistedImpor second := post(body) close(releaseFirst) first := <-firstResult - require.ElementsMatch(t, []int{http.StatusOK, http.StatusAccepted}, []int{first.Code, second.Code}) + require.Equal(t, http.StatusOK, first.Code) + require.Equal(t, http.StatusOK, second.Code) eventCounts := map[beaconevents.EventTopic]int{} for range 3 { eventCounts[(<-events).Event]++ @@ -891,6 +892,11 @@ func TestPostExecutionPayloadEnvelopeConcurrentIdenticalReconcilesPersistedImpor require.Equal(t, 1, eventCounts[beaconevents.OpExecutionPayloadGossip]) require.Equal(t, 1, eventCounts[beaconevents.OpExecutionPayload]) require.Equal(t, 1, eventCounts[beaconevents.OpExecutionPayloadAvailable]) + select { + case event := <-events: + t.Fatalf("duplicate import emitted event %s", event.Event) + default: + } forgedEnvelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)} forgedEnvelope.Message.BeaconBlockRoot = root diff --git a/cl/phase1/network/backward_beacon_downloader.go b/cl/phase1/network/backward_beacon_downloader.go index cc4f5999083..42bd82da160 100644 --- a/cl/phase1/network/backward_beacon_downloader.go +++ b/cl/phase1/network/backward_beacon_downloader.go @@ -70,13 +70,9 @@ type BackwardBeaconDownloader struct { httpFallbackURL string // beacon API base URL for HTTP fallback when P2P fails httpPreferred atomic.Bool // set after first HTTP success; skips P2P probing - // Count consecutive batches where envelope fetch returned 0 for all FULL roots. - // After enough failures, skip envelope requirements and process blocks as EMPTY. + // Count consecutive misses for the required FULL root. consecutiveEnvelopeFailures int - envelopesSkipped bool // set when we give up on envelopes - // FULL blocks that were processed without envelopes due to envelopesSkipped. - // Collected for post-download recovery. skippedFullBlocks []SkippedFullBlock mu sync.Mutex @@ -319,10 +315,7 @@ func (b *BackwardBeaconDownloader) processResponses(ctx context.Context, respons envelope = envelopes[common.Hash(blockRoot)] } - // A FULL block whose envelope could not be fetched must not be treated as - // EMPTY — unless we've exhausted retries (envelopesSkipped is set when - // consecutive batches fail envelope fetch entirely). - if _, isFull := fullRootSet[common.Hash(blockRoot)]; isFull && envelope == nil && !b.envelopesSkipped { + if _, isFull := fullRootSet[common.Hash(blockRoot)]; isFull && envelope == nil { log.Warn("[BackwardBeaconDownloader] GLOAS FULL block envelope missing, will retry", "slot", block.Block.Slot, "consecutiveFailures", b.consecutiveEnvelopeFailures) return nil @@ -335,11 +328,6 @@ func (b *BackwardBeaconDownloader) processResponses(ctx context.Context, respons continue } - // Record FULL blocks passing through without envelope for post-download recovery. - if _, isFull := fullRootSet[common.Hash(blockRoot)]; isFull && envelope == nil { - b.skippedFullBlocks = append(b.skippedFullBlocks, SkippedFullBlock{Block: block, Root: blockRoot}) - } - advanced = true b.expectedRoot = block.Block.ParentRoot if block.Block.Slot == 0 { @@ -373,14 +361,15 @@ func (b *BackwardBeaconDownloader) processResponses(ctx context.Context, respons log.Debug("[BackwardBeaconDownloader] block matched via root lookup", "slot", block.Block.Slot, "root", common.Hash(blockRoot)) var envelope *cltypes.SignedExecutionPayloadEnvelope - if block.Version() >= clparams.GloasVersion && !b.envelopesSkipped { + if block.Version() >= clparams.GloasVersion { env, fetchErr := b.fetchSingleEnvelope(ctx, block) if fetchErr != nil { - log.Warn("[BackwardBeaconDownloader] GLOAS envelope fetch failed for root-fetched block, treating as EMPTY", + log.Warn("[BackwardBeaconDownloader] GLOAS envelope fetch failed for root-fetched block, will retry", "slot", block.Block.Slot, "err", fetchErr) + return nil } - // env == nil && fetchErr == nil means HTTP 404: genuinely EMPTY. envelope = env + b.consecutiveEnvelopeFailures = 0 } finished, err := b.onNewBlock(block, envelope) @@ -452,6 +441,7 @@ func (b *BackwardBeaconDownloader) fetchGloasEnvelopes(ctx context.Context, resp } fullRoots := determineGloasFullRoots(responses, b.prevBatchTopBlock) + optimisticRoot, hasOptimisticRoot := optimisticGloasFullRoot(responses, b.prevBatchTopBlock) // Build a set for O(1) lookup by callers. fullRootSet := make(map[common.Hash]struct{}, len(fullRoots)) @@ -459,17 +449,19 @@ func (b *BackwardBeaconDownloader) fetchGloasEnvelopes(ctx context.Context, resp fullRootSet[common.Hash(r)] = struct{}{} } - if len(fullRoots) == 0 || b.envelopesSkipped { + if len(fullRoots) == 0 { return nil, fullRootSet } // When HTTP has been working, skip the slow P2P envelope fetch entirely. if b.httpPreferred.Load() && b.httpFallbackURL != "" { envelopes := make(map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope, len(fullRoots)) - fetched := fetchEnvelopesFromBeaconAPI(ctx, b.httpFallbackURL, responses, fullRoots, envelopes, b.beaconCfg) - if fetched > 0 { - log.Debug("[BackwardBeaconDownloader] fetched envelopes from beacon API", "count", fetched) + result := fetchEnvelopesFromBeaconAPI(ctx, b.httpFallbackURL, responses, fullRoots, envelopes, b.beaconCfg) + if result.fetched > 0 { + log.Debug("[BackwardBeaconDownloader] fetched envelopes from beacon API", "count", result.fetched) } + b.applyConfirmedOptimisticEmpty(fullRootSet, optimisticRoot, hasOptimisticRoot, result.notFound) + b.recordRequiredEnvelopeAttempt(fullRootSet, envelopes) return envelopes, fullRootSet } @@ -482,27 +474,56 @@ func (b *BackwardBeaconDownloader) fetchGloasEnvelopes(ctx context.Context, resp if envelopes == nil { envelopes = make(map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope, len(fullRoots)) } - fetched := fetchEnvelopesFromBeaconAPI(ctx, b.httpFallbackURL, responses, fullRoots, envelopes, b.beaconCfg) - if fetched > 0 { - log.Debug("[BackwardBeaconDownloader] fetched envelopes from beacon API", "count", fetched) + result := fetchEnvelopesFromBeaconAPI(ctx, b.httpFallbackURL, responses, fullRoots, envelopes, b.beaconCfg) + if result.fetched > 0 { + log.Debug("[BackwardBeaconDownloader] fetched envelopes from beacon API", "count", result.fetched) } + b.applyConfirmedOptimisticEmpty(fullRootSet, optimisticRoot, hasOptimisticRoot, result.notFound) } - // Track consecutive batches where no envelopes could be fetched for FULL roots. - if len(envelopes) == 0 { - b.consecutiveEnvelopeFailures++ - const maxConsecutiveFailures = 3 - if b.consecutiveEnvelopeFailures >= maxConsecutiveFailures && !b.envelopesSkipped { - b.envelopesSkipped = true - log.Warn("[BackwardBeaconDownloader] too many consecutive envelope failures, treating FULL blocks as EMPTY", - "consecutiveFailures", b.consecutiveEnvelopeFailures) - } - } else { + b.recordRequiredEnvelopeAttempt(fullRootSet, envelopes) + + return envelopes, fullRootSet +} + +func optimisticGloasFullRoot(responses []*cltypes.SignedBeaconBlock, prevBatchTopBlock *cltypes.SignedBeaconBlock) (common.Hash, bool) { + if prevBatchTopBlock != nil || len(responses) == 0 { + return common.Hash{}, false + } + block := responses[len(responses)-1] + if block.Version() < clparams.GloasVersion { + return common.Hash{}, false + } + bid := block.Block.Body.GetSignedExecutionPayloadBid() + if bid == nil || bid.Message == nil { + return common.Hash{}, false + } + root, err := block.Block.HashSSZ() + return common.Hash(root), err == nil +} + +func (b *BackwardBeaconDownloader) applyConfirmedOptimisticEmpty(fullRootSet map[common.Hash]struct{}, optimisticRoot common.Hash, hasOptimisticRoot bool, notFound map[common.Hash]struct{}) { + if !hasOptimisticRoot { + return + } + if _, confirmed := notFound[optimisticRoot]; !confirmed { + return + } + delete(fullRootSet, optimisticRoot) + if b.expectedRoot == optimisticRoot { b.consecutiveEnvelopeFailures = 0 - b.envelopesSkipped = false } +} - return envelopes, fullRootSet +func (b *BackwardBeaconDownloader) recordRequiredEnvelopeAttempt(fullRootSet map[common.Hash]struct{}, envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) { + if _, required := fullRootSet[b.expectedRoot]; !required { + return + } + if envelopes[b.expectedRoot] == nil { + b.consecutiveEnvelopeFailures++ + return + } + b.consecutiveEnvelopeFailures = 0 } // SkippedFullBlocks returns FULL blocks that were processed without envelopes @@ -624,20 +645,34 @@ func (b *BackwardBeaconDownloader) canSkipSlot(ctx context.Context, tx kv.Tx, el return false } + var blockHash common.Hash + isGloas := b.beaconCfg != nil && b.beaconCfg.SlotsPerEpoch != 0 && b.beaconCfg.GetCurrentStateVersion(slot/b.beaconCfg.SlotsPerEpoch) >= clparams.GloasVersion + if isGloas { + var err error + blockHash, err = beacon_indicies.ReadExecutionBlockHash(tx, b.expectedRoot) + if err != nil { + log.Warn("Failed to read execution block hash", "err", err) + return false + } + if blockHash == (common.Hash{}) { + return false + } + } + if b.engine == nil || !b.engine.SupportInsertion() { return true } - blockHash, err := beacon_indicies.ReadExecutionBlockHash(tx, b.expectedRoot) - if err != nil { - log.Warn("Failed to read execution block hash", "err", err) - return false - } - if blockHash == (common.Hash{}) { - // [New in Gloas:EIP7732] GLOAS EMPTY blocks have no execution hash (no payload delivered). - // If this slot is in the GLOAS era, no EL processing is needed, so we can skip. - epoch := slot / b.beaconCfg.SlotsPerEpoch - return b.beaconCfg.GetCurrentStateVersion(epoch) >= clparams.GloasVersion + if !isGloas { + var err error + blockHash, err = beacon_indicies.ReadExecutionBlockHash(tx, b.expectedRoot) + if err != nil { + log.Warn("Failed to read execution block hash", "err", err) + return false + } + if blockHash == (common.Hash{}) { + return false + } } blockNumber, err := beacon_indicies.ReadExecutionBlockNumber(tx, b.expectedRoot) @@ -728,7 +763,7 @@ func (b *BackwardBeaconDownloader) fetchSingleEnvelope(ctx context.Context, bloc } client := &http.Client{Timeout: 10 * time.Second} - reqURL := fmt.Sprintf("%s/eth/v1/beacon/execution_payload_envelope/0x%x", b.httpFallbackURL, blockRoot) + reqURL := fmt.Sprintf("%s/eth/v1/beacon/execution_payload_envelopes/0x%x", b.httpFallbackURL, blockRoot) req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, err diff --git a/cl/phase1/network/backward_beacon_downloader_test.go b/cl/phase1/network/backward_beacon_downloader_test.go index 82f1cdb7657..166cf62894c 100644 --- a/cl/phase1/network/backward_beacon_downloader_test.go +++ b/cl/phase1/network/backward_beacon_downloader_test.go @@ -17,18 +17,25 @@ package network import ( + "bytes" "context" "net/http" "net/http/httptest" + "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" + "github.com/erigontech/erigon/cl/phase1/execution_client" "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/kv/dbcfg" + "github.com/erigontech/erigon/db/kv/mdbx/mdbxtest" ) // makeGloasBlock creates a GLOAS SignedBeaconBlock with the given bid hashes. @@ -262,5 +269,268 @@ func TestBackwardBeaconDownloaderFetchEnvelopeUsesRootAndRejectsIdentityMismatch fetched, err := downloader.fetchSingleEnvelope(t.Context(), block) require.ErrorContains(t, err, "root mismatch") require.Nil(t, fetched) - require.Equal(t, "/eth/v1/beacon/execution_payload_envelope/"+common.Hash(blockRoot).Hex(), <-requestedPath) + require.Equal(t, "/eth/v1/beacon/execution_payload_envelopes/"+common.Hash(blockRoot).Hex(), <-requestedPath) +} + +func TestBackwardBeaconDownloaderRootFallbackAdvancesOnlyForEnvelopeOr404(t *testing.T) { + cfg := gloasFromGenesisConfig() + target := makeGloasBlock(10, hash(0xaa), common.Hash{0x42}) + targetRoot, err := target.Block.HashSSZ() + require.NoError(t, err) + encodedBlock, err := target.EncodeSSZ(nil) + require.NoError(t, err) + wrongEnvelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(cfg)} + wrongEnvelope.Message.BeaconBlockRoot = common.HexToHash("0xdead") + encodedWrongEnvelope, err := wrongEnvelope.EncodeSSZ(nil) + require.NoError(t, err) + + tests := []struct { + name string + writeEnvelope func(http.ResponseWriter) + wantProcessed bool + }{ + { + name: "not found confirms empty", + writeEnvelope: func(w http.ResponseWriter) { + http.NotFound(w, nil) + }, + wantProcessed: true, + }, + { + name: "server error", + writeEnvelope: func(w http.ResponseWriter) { + w.WriteHeader(http.StatusInternalServerError) + }, + }, + { + name: "malformed body", + writeEnvelope: func(w http.ResponseWriter) { + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write([]byte{1, 2, 3}) + }, + }, + { + name: "wrong version", + writeEnvelope: func(w http.ResponseWriter) { + w.Header().Set("Eth-Consensus-Version", "fulu") + _, _ = w.Write(encodedWrongEnvelope) + }, + }, + { + name: "wrong root", + writeEnvelope: func(w http.ResponseWriter) { + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encodedWrongEnvelope) + }, + }, + { + name: "oversized body", + writeEnvelope: func(w http.ResponseWriter) { + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(bytes.Repeat([]byte{'x'}, int(clparams.MaxChunkSize)+1)) + }, + }, + { + name: "transport failure", + writeEnvelope: func(http.ResponseWriter) { + panic(http.ErrAbortHandler) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/eth/v2/beacon/blocks/"): + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encodedBlock) + case strings.HasPrefix(r.URL.Path, "/eth/v1/beacon/execution_payload_envelopes/"): + tt.writeEnvelope(w) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + downloader := &BackwardBeaconDownloader{ + expectedRoot: targetRoot, + httpFallbackURL: server.URL, + beaconCfg: cfg, + } + downloader.slotToDownload.Store(target.Block.Slot) + processed := 0 + downloader.SetOnNewBlock(func(_ *cltypes.SignedBeaconBlock, envelope *cltypes.SignedExecutionPayloadEnvelope) (bool, error) { + processed++ + require.Nil(t, envelope) + return false, nil + }) + + require.NoError(t, downloader.processResponses(t.Context(), []*cltypes.SignedBeaconBlock{makeDenebBlock(11)})) + if tt.wantProcessed { + require.Equal(t, 1, processed) + require.Equal(t, target.Block.ParentRoot, downloader.expectedRoot) + require.Equal(t, target.Block.Slot-1, downloader.Progress()) + } else { + require.Zero(t, processed) + require.Equal(t, common.Hash(targetRoot), downloader.expectedRoot) + require.Equal(t, target.Block.Slot, downloader.Progress()) + } + }) + } +} + +func TestBackwardBeaconDownloaderMissingRequiredEnvelopeSurvivesRestart(t *testing.T) { + cfg := gloasFromGenesisConfig() + target := makeGloasBlock(10, hash(0xaa), common.Hash{0x42}) + lookahead := makeGloasBlock(11, hash(0xbb), hash(0xaa)) + linkBeaconBlocks(t, target, lookahead) + targetRoot, err := target.Block.HashSSZ() + require.NoError(t, err) + lookaheadRoot, err := lookahead.Block.HashSSZ() + require.NoError(t, err) + targetEnvelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(cfg)} + targetEnvelope.Message.BeaconBlockRoot = targetRoot + encodedTargetEnvelope, err := targetEnvelope.EncodeSSZ(nil) + require.NoError(t, err) + lookaheadEnvelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(cfg)} + lookaheadEnvelope.Message.BeaconBlockRoot = lookaheadRoot + encodedLookaheadEnvelope, err := lookaheadEnvelope.EncodeSSZ(nil) + require.NoError(t, err) + + recovered := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Eth-Consensus-Version", "gloas") + switch { + case strings.HasSuffix(r.URL.Path, common.Hash(lookaheadRoot).Hex()): + _, _ = w.Write(encodedLookaheadEnvelope) + case strings.HasSuffix(r.URL.Path, common.Hash(targetRoot).Hex()) && recovered: + _, _ = w.Write(encodedTargetEnvelope) + default: + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer server.Close() + + downloader := &BackwardBeaconDownloader{ + expectedRoot: targetRoot, + httpFallbackURL: server.URL, + beaconCfg: cfg, + } + downloader.httpPreferred.Store(true) + downloader.slotToDownload.Store(target.Block.Slot) + processed := 0 + downloader.SetOnNewBlock(func(_ *cltypes.SignedBeaconBlock, _ *cltypes.SignedExecutionPayloadEnvelope) (bool, error) { + processed++ + return false, nil + }) + + for range 3 { + require.NoError(t, downloader.processResponses(t.Context(), []*cltypes.SignedBeaconBlock{target, lookahead})) + } + require.Equal(t, 3, downloader.consecutiveEnvelopeFailures) + require.Zero(t, processed) + require.False(t, downloader.Finished()) + require.Equal(t, common.Hash(targetRoot), downloader.expectedRoot) + require.Equal(t, target.Block.Slot, downloader.Progress()) + require.Empty(t, downloader.SkippedFullBlocks()) + + recovered = true + restarted := &BackwardBeaconDownloader{ + expectedRoot: targetRoot, + httpFallbackURL: server.URL, + beaconCfg: cfg, + consecutiveEnvelopeFailures: 2, + } + restarted.httpPreferred.Store(true) + restarted.slotToDownload.Store(target.Block.Slot) + restartedProcessed := 0 + restarted.SetOnNewBlock(func(_ *cltypes.SignedBeaconBlock, envelope *cltypes.SignedExecutionPayloadEnvelope) (bool, error) { + restartedProcessed++ + require.Equal(t, targetEnvelope.Message.BeaconBlockRoot, envelope.Message.BeaconBlockRoot) + return true, nil + }) + + require.NoError(t, restarted.processResponses(t.Context(), []*cltypes.SignedBeaconBlock{target, lookahead})) + require.Zero(t, restarted.consecutiveEnvelopeFailures) + require.Equal(t, 1, restartedProcessed) + require.True(t, restarted.Finished()) + require.Equal(t, target.Block.ParentRoot, restarted.expectedRoot) +} + +func TestBackwardBeaconDownloaderInitialEmptyAdvancesOnHTTP404(t *testing.T) { + cfg := gloasFromGenesisConfig() + target := makeGloasBlock(10, hash(0xaa), common.Hash{0x42}) + targetRoot, err := target.Block.HashSSZ() + require.NoError(t, err) + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + downloader := &BackwardBeaconDownloader{ + expectedRoot: targetRoot, + httpFallbackURL: server.URL, + beaconCfg: cfg, + } + downloader.httpPreferred.Store(true) + downloader.slotToDownload.Store(target.Block.Slot) + processed := 0 + downloader.SetOnNewBlock(func(_ *cltypes.SignedBeaconBlock, envelope *cltypes.SignedExecutionPayloadEnvelope) (bool, error) { + processed++ + require.Nil(t, envelope) + return false, nil + }) + + require.NoError(t, downloader.processResponses(t.Context(), []*cltypes.SignedBeaconBlock{target})) + require.Equal(t, 1, processed) + require.Equal(t, target.Block.ParentRoot, downloader.expectedRoot) + require.Equal(t, target.Block.Slot-1, downloader.Progress()) +} + +func TestBackwardBeaconDownloaderProvenFullDoesNotAdvanceOnHTTP404(t *testing.T) { + cfg := gloasFromGenesisConfig() + target := makeGloasBlock(10, hash(0xaa), common.Hash{0x42}) + lookahead := makeGloasBlock(11, hash(0xbb), hash(0xaa)) + linkBeaconBlocks(t, target, lookahead) + targetRoot, err := target.Block.HashSSZ() + require.NoError(t, err) + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + downloader := &BackwardBeaconDownloader{ + expectedRoot: targetRoot, + httpFallbackURL: server.URL, + beaconCfg: cfg, + } + downloader.httpPreferred.Store(true) + downloader.slotToDownload.Store(target.Block.Slot) + processed := 0 + downloader.SetOnNewBlock(func(_ *cltypes.SignedBeaconBlock, _ *cltypes.SignedExecutionPayloadEnvelope) (bool, error) { + processed++ + return false, nil + }) + + require.NoError(t, downloader.processResponses(t.Context(), []*cltypes.SignedBeaconBlock{target, lookahead})) + require.Zero(t, processed) + require.Equal(t, common.Hash(targetRoot), downloader.expectedRoot) + require.Equal(t, target.Block.Slot, downloader.Progress()) +} + +func TestBackwardBeaconDownloaderDoesNotSkipUnknownGloasPayload(t *testing.T) { + db := mdbxtest.NewTestDB(t, dbcfg.ChainDB) + defer db.Close() + + require.NoError(t, db.View(t.Context(), func(tx kv.Tx) error { + withoutEngine := &BackwardBeaconDownloader{ + beaconCfg: gloasFromGenesisConfig(), + expectedRoot: common.Hash{0xaa}, + } + require.False(t, withoutEngine.canSkipSlot(t.Context(), tx, 0, 0, 10)) + + engine := execution_client.NewMockExecutionEngine(gomock.NewController(t)) + withEngine := &BackwardBeaconDownloader{ + engine: engine, + beaconCfg: gloasFromGenesisConfig(), + expectedRoot: common.Hash{0xaa}, + } + require.False(t, withEngine.canSkipSlot(t.Context(), tx, 0, 0, 10)) + return nil + })) } diff --git a/cl/phase1/network/beacon_downloader.go b/cl/phase1/network/beacon_downloader.go index f8ec66cb11d..5a9386b3560 100644 --- a/cl/phase1/network/beacon_downloader.go +++ b/cl/phase1/network/beacon_downloader.go @@ -409,9 +409,9 @@ Process: // batch — skip the 30s P2P envelope timeout and fetch directly via HTTP. if pid == "http-fallback" && f.httpFallbackURL != "" { envelopes = make(map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) - httpEnvs := fetchEnvelopesFromBeaconAPI(ctx, f.httpFallbackURL, processBlocks, fullRoots, envelopes, f.beaconCfg) - if httpEnvs > 0 { - log.Debug("[ForwardBeaconDownloader] fetched envelopes from beacon API", "count", httpEnvs) + httpResult := fetchEnvelopesFromBeaconAPI(ctx, f.httpFallbackURL, processBlocks, fullRoots, envelopes, f.beaconCfg) + if httpResult.fetched > 0 { + log.Debug("[ForwardBeaconDownloader] fetched envelopes from beacon API", "count", httpResult.fetched) } } else { var envErr error @@ -424,9 +424,9 @@ Process: if envelopes == nil { envelopes = make(map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) } - httpEnvs := fetchEnvelopesFromBeaconAPI(ctx, f.httpFallbackURL, processBlocks, fullRoots, envelopes, f.beaconCfg) - if httpEnvs > 0 { - log.Debug("[ForwardBeaconDownloader] fetched envelopes from beacon API", "count", httpEnvs) + httpResult := fetchEnvelopesFromBeaconAPI(ctx, f.httpFallbackURL, processBlocks, fullRoots, envelopes, f.beaconCfg) + if httpResult.fetched > 0 { + log.Debug("[ForwardBeaconDownloader] fetched envelopes from beacon API", "count", httpResult.fetched) } } } @@ -889,8 +889,12 @@ func validateHTTPBlockVersion(beaconCfg *clparams.BeaconChainConfig, slot uint64 return nil } -// fetchEnvelopesFromBeaconAPI fetches execution payload envelopes from the beacon API -// for FULL blocks whose envelopes were not received via P2P. +type fetchEnvelopeHTTPResult struct { + fetched int + notFound map[common.Hash]struct{} +} + +// fetchEnvelopesFromBeaconAPI fetches execution payload envelopes from the beacon API for FULL blocks missing from P2P. func fetchEnvelopesFromBeaconAPI( ctx context.Context, baseURL string, @@ -898,7 +902,7 @@ func fetchEnvelopesFromBeaconAPI( fullRoots [][32]byte, received map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope, beaconCfg *clparams.BeaconChainConfig, -) int { +) fetchEnvelopeHTTPResult { // Build root-to-slot mapping from blocks rootToSlot := make(map[common.Hash]uint64, len(blocks)) for _, blk := range blocks { @@ -911,6 +915,7 @@ func fetchEnvelopesFromBeaconAPI( type envResult struct { hash common.Hash envelope *cltypes.SignedExecutionPayloadEnvelope + notFound bool } // Filter roots that need fetching @@ -934,7 +939,7 @@ func fetchEnvelopesFromBeaconAPI( } if len(toFetch) == 0 { - return 0 + return fetchEnvelopeHTTPResult{} } results := make([]envResult, len(toFetch)) @@ -950,7 +955,7 @@ func fetchEnvelopesFromBeaconAPI( sem <- struct{}{} defer func() { <-sem }() - reqURL := fmt.Sprintf("%s/eth/v1/beacon/execution_payload_envelope/0x%x", baseURL, root) + reqURL := fmt.Sprintf("%s/eth/v1/beacon/execution_payload_envelopes/0x%x", baseURL, root) req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return @@ -962,7 +967,14 @@ func fetchEnvelopesFromBeaconAPI( return } body, err := readBeaconAPIResponseBody(resp) - if err != nil || resp.StatusCode != http.StatusOK { + if err != nil { + return + } + if resp.StatusCode == http.StatusNotFound { + results[idx] = envResult{hash: common.Hash(root), notFound: true} + return + } + if resp.StatusCode != http.StatusOK { return } version, err := httpConsensusVersion(resp.Header.Get("Eth-Consensus-Version")) @@ -985,14 +997,17 @@ func fetchEnvelopesFromBeaconAPI( } wg.Wait() - fetched := 0 + result := fetchEnvelopeHTTPResult{notFound: make(map[common.Hash]struct{})} for _, r := range results { if r.envelope != nil { received[r.hash] = r.envelope - fetched++ + result.fetched++ + } + if r.notFound { + result.notFound[r.hash] = struct{}{} } } - return fetched + return result } // GetHighestProcessedSlot retrieve the highest processed slot we accumulated. diff --git a/cl/phase1/network/beacon_downloader_test.go b/cl/phase1/network/beacon_downloader_test.go index be52458f765..f2cc72a3fa3 100644 --- a/cl/phase1/network/beacon_downloader_test.go +++ b/cl/phase1/network/beacon_downloader_test.go @@ -210,10 +210,10 @@ func TestFetchEnvelopesFromBeaconAPIUsesRootAndRejectsIdentityMismatch(t *testin defer server.Close() received := make(map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) - fetched := fetchEnvelopesFromBeaconAPI(t.Context(), server.URL, []*cltypes.SignedBeaconBlock{block}, [][32]byte{blockRoot}, received, cfg) - require.Zero(t, fetched) + result := fetchEnvelopesFromBeaconAPI(t.Context(), server.URL, []*cltypes.SignedBeaconBlock{block}, [][32]byte{blockRoot}, received, cfg) + require.Zero(t, result.fetched) require.Empty(t, received) - require.Equal(t, "/eth/v1/beacon/execution_payload_envelope/0x"+common.Hash(blockRoot).Hex()[2:], <-requestedPath) + require.Equal(t, "/eth/v1/beacon/execution_payload_envelopes/0x"+common.Hash(blockRoot).Hex()[2:], <-requestedPath) } func TestForwardBeaconDownloaderHTTPFailureWithoutP2PReturnsSafely(t *testing.T) { diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index d9654e82b3a..dd735f5f1ab 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -46,6 +46,7 @@ import ( var ErrInvalidSignature = errors.New("invalid signature") var ErrPublishedBlockJobExpired = errors.New("published block integration expired") +var ErrPublishedBlockJobStopped = errors.New("block service stopped") type proposerIndexAndSlot struct { proposerIndex uint64 @@ -163,6 +164,8 @@ type blockService struct { // blocks that should be scheduled for later execution (e.g missing blobs). emitter *beaconevents.EventEmitter blocksScheduledForLaterExecution sync.Map + blockJobsLifecycleMu sync.RWMutex + blockJobsStopped bool // store the block in db db kv.RwDB } @@ -191,6 +194,7 @@ func NewBlockService( emitter: emitter, db: db, } + go b.stopPublishedBlockJobsOnContext(ctx) go b.loop(ctx) return b } @@ -651,14 +655,11 @@ func (b *blockService) ScheduleBlockForLaterProcessing(block *cltypes.SignedBeac } func (b *blockService) SchedulePublishedBlockForLaterProcessing(block *cltypes.SignedBeaconBlock, store func(context.Context) error) PublishedBlockJob { - job := b.scheduleBlockForLaterProcessing(block, store) - job.mu.Lock() - handle := &publishedBlockJobHandle{job: job, generation: job.storeGeneration} - job.mu.Unlock() - return handle + job, generation := b.scheduleBlockForLaterProcessing(block, store) + return &publishedBlockJobHandle{job: job, generation: generation} } -func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeaconBlock, store func(context.Context) error) *blockJob { +func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeaconBlock, store func(context.Context) error) (*blockJob, uint64) { // [Modified in Gloas:EIP7732] ExecutionPayload is not in block.body for GLOAS var blockNum uint64 if block.Block.Body.ExecutionPayload != nil { @@ -668,22 +669,42 @@ func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeac blockRoot, err := block.Block.HashSSZ() if err != nil { log.Debug("Failed to hash block", "block", block, "error", err) - return newFailedBlockJob(block, store, err) + job := newFailedBlockJob(block, store, err) + return job, job.storeGeneration } job := newBlockJob(block, store) - existingValue, loaded := b.blocksScheduledForLaterExecution.LoadOrStore(blockRoot, job) - if !loaded { - return job + jobGeneration := job.storeGeneration + b.blockJobsLifecycleMu.RLock() + defer b.blockJobsLifecycleMu.RUnlock() + if b.blockJobsStopped { + job = newFailedBlockJob(block, store, ErrPublishedBlockJobStopped) + return job, job.storeGeneration } - existing := existingValue.(*blockJob) - if store == nil { - return existing + for { + existingValue, loaded := b.blocksScheduledForLaterExecution.LoadOrStore(blockRoot, job) + if !loaded { + return job, jobGeneration + } + existing, generation := b.reuseScheduledBlockJob(blockRoot, existingValue.(*blockJob), job, store) + if existing != nil { + return existing, generation + } } +} + +func (b *blockService) reuseScheduledBlockJob(key [32]byte, existing, job *blockJob, store func(context.Context) error) (*blockJob, uint64) { existing.mu.Lock() defer existing.mu.Unlock() + current, ok := b.blocksScheduledForLaterExecution.Load(key) + if !ok || current != existing { + return nil, 0 + } + if store == nil { + return existing, existing.storeGeneration + } if !job.creationTime.After(existing.creationTime) { - return existing + return existing, existing.storeGeneration } existing.store = store existing.storeGeneration++ @@ -692,7 +713,7 @@ func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeac existing.terminal = false existing.attempt = &blockJobAttempt{done: make(chan struct{})} } - return existing + return existing, existing.storeGeneration } // processAndStoreBlock processes and stores a block @@ -756,6 +777,7 @@ func (b *blockService) loop(ctx context.Context) { for { select { case <-ctx.Done(): + b.stopPublishedBlockJobs() return case <-ticker.C: } @@ -766,6 +788,40 @@ func (b *blockService) loop(ctx context.Context) { } } +func (b *blockService) stopPublishedBlockJobsOnContext(ctx context.Context) { + <-ctx.Done() + b.stopPublishedBlockJobs() +} + +func (b *blockService) stopPublishedBlockJobs() { + b.blockJobsLifecycleMu.Lock() + if b.blockJobsStopped { + b.blockJobsLifecycleMu.Unlock() + return + } + b.blockJobsStopped = true + b.blockJobsLifecycleMu.Unlock() + + b.blocksScheduledForLaterExecution.Range(func(key, value any) bool { + job := value.(*blockJob) + job.mu.Lock() + current, ok := b.blocksScheduledForLaterExecution.Load(key) + if ok && current == job { + if !job.terminal { + job.attempt.err = ErrPublishedBlockJobStopped + job.attempt.generation = job.storeGeneration + job.lastAttempt = job.attempt + job.completedGeneration = job.storeGeneration + job.terminal = true + close(job.attempt.done) + } + b.blocksScheduledForLaterExecution.CompareAndDelete(key, job) + } + job.mu.Unlock() + return true + }) +} + func (b *blockService) processScheduledBlock(ctx context.Context, key [32]byte, job *blockJob, now time.Time) { job.mu.Lock() if job.running { @@ -781,8 +837,8 @@ func (b *blockService) processScheduledBlock(ctx context.Context, key [32]byte, job.terminal = true close(job.attempt.done) } - job.mu.Unlock() b.blocksScheduledForLaterExecution.CompareAndDelete(key, job) + job.mu.Unlock() return } if job.terminal { @@ -800,6 +856,10 @@ func (b *blockService) processScheduledBlock(ctx context.Context, key [32]byte, err := store(ctx) job.mu.Lock() job.running = false + if job.terminal && job.completedGeneration >= generation { + job.mu.Unlock() + return + } attempt.err = err attempt.generation = generation close(attempt.done) @@ -812,10 +872,10 @@ func (b *blockService) processScheduledBlock(ctx context.Context, key [32]byte, } else { job.attempt = &blockJobAttempt{done: make(chan struct{})} } - job.mu.Unlock() if terminal { b.blocksScheduledForLaterExecution.CompareAndDelete(key, job) } + job.mu.Unlock() if err != nil { log.Trace("Failed to process and store block", "block", job.block, "error", err) return diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index 95492984b9c..37ab63c6e39 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -1096,6 +1096,155 @@ func TestPublishedBlockJobHashFailureWaitIsReplayable(t *testing.T) { require.Zero(t, count) } +func TestPublishedBlockJobDetachedTerminalCannotReplaceCurrentJob(t *testing.T) { + service := &blockService{} + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + detached := newBlockJob(block, func(context.Context) error { return nil }) + detached.terminal = true + current := newBlockJob(block, func(context.Context) error { return nil }) + service.blocksScheduledForLaterExecution.Store(root, current) + candidate := newBlockJob(block, func(context.Context) error { return nil }) + + reused, _ := service.reuseScheduledBlockJob(root, detached, candidate, candidate.store) + + require.Nil(t, reused) + stored, ok := service.blocksScheduledForLaterExecution.Load(root) + require.True(t, ok) + require.Same(t, current, stored) +} + +func TestPublishedBlockJobExpiryRescheduleKeepsFreshStore(t *testing.T) { + service := &blockService{} + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + expiredHandle := service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil }) + job := serviceJob(t, service, root) + job.mu.Lock() + expireStarted := make(chan struct{}) + expireDone := make(chan struct{}) + go func() { + close(expireStarted) + service.processScheduledBlock(context.Background(), root, job, job.creationTime.Add(blockJobExpiry+time.Second)) + close(expireDone) + }() + <-expireStarted + freshStoreCalls := 0 + rescheduleStarted := make(chan struct{}) + rescheduleDone := make(chan PublishedBlockJob, 1) + go func() { + close(rescheduleStarted) + rescheduleDone <- service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + freshStoreCalls++ + return nil + }) + }() + <-rescheduleStarted + job.mu.Unlock() + <-expireDone + require.ErrorIs(t, expiredHandle.Wait(t.Context()), ErrPublishedBlockJobExpired) + freshHandle := <-rescheduleDone + freshJob := serviceJob(t, service, root) + require.NotSame(t, job, freshJob) + service.processScheduledBlock(context.Background(), root, freshJob, time.Now()) + require.NoError(t, freshHandle.Wait(t.Context())) + require.Equal(t, 1, freshStoreCalls) +} + +func TestPublishedBlockJobShutdownClosesQueuedWaitersAndRejectsNewSchedules(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cfg := clparams.MainnetBeaconConfig + service := NewBlockService(ctx, nil, nil, nil, nil, &cfg, nil).(*blockService) + block := cltypes.NewSignedBeaconBlock(&cfg, clparams.Phase0Version) + secondBlock := cltypes.NewSignedBeaconBlock(&cfg, clparams.Phase0Version) + secondBlock.Block.Slot = 1 + handles := []PublishedBlockJob{ + service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil }), + service.SchedulePublishedBlockForLaterProcessing(secondBlock, func(context.Context) error { return nil }), + } + cancel() + waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second) + defer waitCancel() + for _, handle := range handles { + require.ErrorIs(t, handle.Wait(waitCtx), ErrPublishedBlockJobStopped) + } + + lateHandle := service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil }) + require.ErrorIs(t, lateHandle.Wait(waitCtx), ErrPublishedBlockJobStopped) +} + +func TestPublishedBlockJobShutdownOwnsRunningAttemptAndIgnoresLateResult(t *testing.T) { + service := &blockService{} + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + storeStarted := make(chan struct{}) + storeRelease := make(chan struct{}) + handle := service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + close(storeStarted) + <-storeRelease + return nil + }) + job := serviceJob(t, service, root) + processDone := make(chan struct{}) + go func() { + service.processScheduledBlock(context.Background(), root, job, time.Now()) + close(processDone) + }() + <-storeStarted + service.stopPublishedBlockJobs() + waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second) + defer waitCancel() + require.ErrorIs(t, handle.Wait(waitCtx), ErrPublishedBlockJobStopped) + close(storeRelease) + <-processDone + require.ErrorIs(t, handle.Wait(waitCtx), ErrPublishedBlockJobStopped) + _, scheduled := service.blocksScheduledForLaterExecution.Load(root) + require.False(t, scheduled) +} + +func TestPublishedBlockJobShutdownPreservesCompletedAttempt(t *testing.T) { + service := &blockService{} + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + handle := service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil }) + service.processScheduledBlock(context.Background(), root, serviceJob(t, service, root), time.Now()) + require.NoError(t, handle.Wait(t.Context())) + + service.stopPublishedBlockJobs() + + require.NoError(t, handle.Wait(t.Context())) +} + +func TestPublishedBlockJobConcurrentSchedulesReturnWaitableHandles(t *testing.T) { + service := &blockService{} + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + const schedules = 32 + start := make(chan struct{}) + handles := make(chan PublishedBlockJob, schedules) + var wg sync.WaitGroup + for range schedules { + wg.Add(1) + go func() { + defer wg.Done() + <-start + handles <- service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil }) + }() + } + close(start) + wg.Wait() + close(handles) + service.processScheduledBlock(context.Background(), root, serviceJob(t, service, root), time.Now()) + for handle := range handles { + require.NoError(t, handle.Wait(t.Context())) + } +} + func serviceJob(t *testing.T, service *blockService, root [32]byte) *blockJob { t.Helper() job, ok := service.blocksScheduledForLaterExecution.Load(root) diff --git a/cl/phase1/network/services/execution_payload_bid_service.go b/cl/phase1/network/services/execution_payload_bid_service.go index 3e446021979..115f4e4dd86 100644 --- a/cl/phase1/network/services/execution_payload_bid_service.go +++ b/cl/phase1/network/services/execution_payload_bid_service.go @@ -41,8 +41,10 @@ import ( ) type seenBidKey struct { - builderIndex uint64 - slot uint64 + slot uint64 + parentBlockHash common.Hash + parentBlockRoot common.Hash + builderIndex uint64 } type seenBidStore struct { @@ -82,8 +84,10 @@ func (s *seenBidStore) PruneExpired(clock eth_clock.EthereumClock, beaconCfg *cl func newSeenBidKey(bid *cltypes.ExecutionPayloadBid) seenBidKey { return seenBidKey{ - builderIndex: bid.BuilderIndex, - slot: bid.Slot, + slot: bid.Slot, + parentBlockHash: bid.ParentBlockHash, + parentBlockRoot: bid.ParentBlockRoot, + builderIndex: bid.BuilderIndex, } } diff --git a/cl/phase1/network/services/execution_payload_bid_service_test.go b/cl/phase1/network/services/execution_payload_bid_service_test.go index c549e805083..f873f9a2e03 100644 --- a/cl/phase1/network/services/execution_payload_bid_service_test.go +++ b/cl/phase1/network/services/execution_payload_bid_service_test.go @@ -637,12 +637,17 @@ func TestExecutionPayloadBidServiceUsesCoherentHeadNodeSnapshot(t *testing.T) { require.True(t, compatible) } -func TestSeenBidKeyIsBuilderAndSlotOnly(t *testing.T) { +func TestSeenBidKeyIncludesParentTuple(t *testing.T) { bid1 := newTestSignedExecutionPayloadBid(100, 1, 1000).Message bid2 := newTestSignedExecutionPayloadBid(100, 1, 1001).Message bid2.ParentBlockRoot = common.HexToHash("0xdddd") - require.Equal(t, newSeenBidKey(bid1), newSeenBidKey(bid2)) + require.NotEqual(t, newSeenBidKey(bid1), newSeenBidKey(bid2)) + + bid2.ParentBlockRoot = bid1.ParentBlockRoot + bid2.ParentBlockHash = common.HexToHash("0xdddd") + require.NotEqual(t, newSeenBidKey(bid1), newSeenBidKey(bid2)) + bid2.ParentBlockHash = bid1.ParentBlockHash bid2.BuilderIndex++ require.NotEqual(t, newSeenBidKey(bid1), newSeenBidKey(bid2)) } @@ -975,7 +980,7 @@ func TestExecutionPayloadBidServiceRejectsLowerBidBeforeStateFetch(t *testing.T) require.Zero(t, service.validationStateCache.Len()) } -func TestExecutionPayloadBidServiceRejectsSecondBidFromBuilderAtSameSlot(t *testing.T) { +func TestExecutionPayloadBidServiceAcceptsSameBuilderAtSameSlotForDifferentParent(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -1007,16 +1012,16 @@ func TestExecutionPayloadBidServiceRejectsSecondBidFromBuilderAtSameSlot(t *test ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)) err = service.ProcessMessage(context.Background(), nil, msg2) - require.ErrorIs(t, err, ErrIgnore) - require.Contains(t, err.Error(), "already seen bid") + require.NoError(t, err) bidKey1 := pool.HighestBidKey{Slot: 100, ParentBlockHash: parentHash1, ParentBlockRoot: parentRoot1} bidKey2 := pool.HighestBidKey{Slot: 100, ParentBlockHash: parentHash2, ParentBlockRoot: parentRoot2} stored1, found1 := epbsPool.HighestBids.Get(bidKey1) - _, found2 := epbsPool.HighestBids.Get(bidKey2) + stored2, found2 := epbsPool.HighestBids.Get(bidKey2) require.True(t, found1) - require.False(t, found2) + require.True(t, found2) require.Equal(t, uint64(1000), stored1.Message.Value) + require.Equal(t, uint64(500), stored2.Message.Value) } func TestExecutionPayloadBidServiceSuccess(t *testing.T) { From 28acfc441f9be30825f3d785485616a3c49bb88e Mon Sep 17 00:00:00 2001 From: kewei Date: Fri, 28 Aug 2026 12:40:17 +0800 Subject: [PATCH 42/43] cl: preserve backward Gloas retry ownership --- .../network/backward_beacon_downloader.go | 80 ++-- .../backward_beacon_downloader_test.go | 351 +++++++++++++++++- cl/phase1/network/beacon_downloader.go | 10 +- .../network/services/block_service_test.go | 6 +- 4 files changed, 369 insertions(+), 78 deletions(-) diff --git a/cl/phase1/network/backward_beacon_downloader.go b/cl/phase1/network/backward_beacon_downloader.go index 42bd82da160..1f5c0ed19c7 100644 --- a/cl/phase1/network/backward_beacon_downloader.go +++ b/cl/phase1/network/backward_beacon_downloader.go @@ -78,6 +78,8 @@ type BackwardBeaconDownloader struct { mu sync.Mutex } +var errExecutionPayloadEnvelopeNotFound = errors.New("execution payload envelope not found") + // SkippedFullBlock records a GLOAS FULL block whose envelope was unavailable during backward download. type SkippedFullBlock struct { Block *cltypes.SignedBeaconBlock @@ -288,10 +290,6 @@ func (b *BackwardBeaconDownloader) processResponses(ctx context.Context, respons envelopes, fullRootSet := b.fetchGloasEnvelopes(ctx, responses) log.Debug("[BackwardBeaconDownloader] envelopes fetched", "count", len(envelopes), "fullRoots", len(fullRootSet)) - // Track whether any block was successfully processed. Only update - // prevBatchTopBlock when we advance, to avoid corrupting the lookahead - // when a retry causes the same batch to be re-fetched. - advanced := false matched := false for _, block := range slices.Backward(responses) { if b.finished.Load() { @@ -316,36 +314,30 @@ func (b *BackwardBeaconDownloader) processResponses(ctx context.Context, respons } if _, isFull := fullRootSet[common.Hash(blockRoot)]; isFull && envelope == nil { + b.httpPreferred.Store(false) log.Warn("[BackwardBeaconDownloader] GLOAS FULL block envelope missing, will retry", "slot", block.Block.Slot, "consecutiveFailures", b.consecutiveEnvelopeFailures) return nil } finished, err := b.onNewBlock(block, envelope) - b.finished.Store(finished) if err != nil { + b.httpPreferred.Store(false) log.Warn("Error processing block", "err", err) - continue + return nil } - - advanced = true b.expectedRoot = block.Block.ParentRoot + b.prevBatchTopBlock = block if block.Block.Slot == 0 { b.finished.Store(true) - b.prevBatchTopBlock = responses[0] return nil } b.slotToDownload.Store(block.Block.Slot - 1) - } - - // Update prevBatchTopBlock only when at least one block was processed, - // so retries preserve the correct lookahead for FULL/EMPTY determination. - if advanced && len(responses) > 0 { - b.prevBatchTopBlock = responses[0] + b.finished.Store(finished) } if !matched { - log.Debug("[BackwardBeaconDownloader] no root match in batch", "expectedRoot", b.expectedRoot, "responses", len(responses), "advanced", advanced) + log.Debug("[BackwardBeaconDownloader] no root match in batch", "expectedRoot", b.expectedRoot, "responses", len(responses)) } // When slot-based fetching found no match, the expected block may be on the @@ -364,6 +356,7 @@ func (b *BackwardBeaconDownloader) processResponses(ctx context.Context, respons if block.Version() >= clparams.GloasVersion { env, fetchErr := b.fetchSingleEnvelope(ctx, block) if fetchErr != nil { + b.httpPreferred.Store(false) log.Warn("[BackwardBeaconDownloader] GLOAS envelope fetch failed for root-fetched block, will retry", "slot", block.Block.Slot, "err", fetchErr) return nil @@ -373,17 +366,19 @@ func (b *BackwardBeaconDownloader) processResponses(ctx context.Context, respons } finished, err := b.onNewBlock(block, envelope) - b.finished.Store(finished) if err != nil { + b.httpPreferred.Store(false) log.Warn("Error processing root-fetched block", "err", err) - } else { - b.expectedRoot = block.Block.ParentRoot - if block.Block.Slot == 0 { - b.finished.Store(true) - return nil - } - b.slotToDownload.Store(block.Block.Slot - 1) + return nil + } + b.expectedRoot = block.Block.ParentRoot + b.prevBatchTopBlock = block + if block.Block.Slot == 0 { + b.finished.Store(true) + return nil } + b.slotToDownload.Store(block.Block.Slot - 1) + b.finished.Store(finished) } } } @@ -441,7 +436,6 @@ func (b *BackwardBeaconDownloader) fetchGloasEnvelopes(ctx context.Context, resp } fullRoots := determineGloasFullRoots(responses, b.prevBatchTopBlock) - optimisticRoot, hasOptimisticRoot := optimisticGloasFullRoot(responses, b.prevBatchTopBlock) // Build a set for O(1) lookup by callers. fullRootSet := make(map[common.Hash]struct{}, len(fullRoots)) @@ -460,7 +454,6 @@ func (b *BackwardBeaconDownloader) fetchGloasEnvelopes(ctx context.Context, resp if result.fetched > 0 { log.Debug("[BackwardBeaconDownloader] fetched envelopes from beacon API", "count", result.fetched) } - b.applyConfirmedOptimisticEmpty(fullRootSet, optimisticRoot, hasOptimisticRoot, result.notFound) b.recordRequiredEnvelopeAttempt(fullRootSet, envelopes) return envelopes, fullRootSet } @@ -478,7 +471,6 @@ func (b *BackwardBeaconDownloader) fetchGloasEnvelopes(ctx context.Context, resp if result.fetched > 0 { log.Debug("[BackwardBeaconDownloader] fetched envelopes from beacon API", "count", result.fetched) } - b.applyConfirmedOptimisticEmpty(fullRootSet, optimisticRoot, hasOptimisticRoot, result.notFound) } b.recordRequiredEnvelopeAttempt(fullRootSet, envelopes) @@ -486,35 +478,6 @@ func (b *BackwardBeaconDownloader) fetchGloasEnvelopes(ctx context.Context, resp return envelopes, fullRootSet } -func optimisticGloasFullRoot(responses []*cltypes.SignedBeaconBlock, prevBatchTopBlock *cltypes.SignedBeaconBlock) (common.Hash, bool) { - if prevBatchTopBlock != nil || len(responses) == 0 { - return common.Hash{}, false - } - block := responses[len(responses)-1] - if block.Version() < clparams.GloasVersion { - return common.Hash{}, false - } - bid := block.Block.Body.GetSignedExecutionPayloadBid() - if bid == nil || bid.Message == nil { - return common.Hash{}, false - } - root, err := block.Block.HashSSZ() - return common.Hash(root), err == nil -} - -func (b *BackwardBeaconDownloader) applyConfirmedOptimisticEmpty(fullRootSet map[common.Hash]struct{}, optimisticRoot common.Hash, hasOptimisticRoot bool, notFound map[common.Hash]struct{}) { - if !hasOptimisticRoot { - return - } - if _, confirmed := notFound[optimisticRoot]; !confirmed { - return - } - delete(fullRootSet, optimisticRoot) - if b.expectedRoot == optimisticRoot { - b.consecutiveEnvelopeFailures = 0 - } -} - func (b *BackwardBeaconDownloader) recordRequiredEnvelopeAttempt(fullRootSet map[common.Hash]struct{}, envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) { if _, required := fullRootSet[b.expectedRoot]; !required { return @@ -748,8 +711,7 @@ func fetchBlockFromBeaconAPIByRoot(ctx context.Context, baseURL string, root com } // fetchSingleEnvelope fetches the execution payload envelope for a single GLOAS block. -// Returns (envelope, nil) on success, (nil, nil) when the beacon API confirms the slot -// has no envelope (HTTP 404 = genuinely EMPTY), or (nil, err) on fetch failure. +// Returns an envelope on success and an error for every unavailable response. func (b *BackwardBeaconDownloader) fetchSingleEnvelope(ctx context.Context, block *cltypes.SignedBeaconBlock) (*cltypes.SignedExecutionPayloadEnvelope, error) { if b.httpFallbackURL == "" { return nil, fmt.Errorf("no HTTP fallback URL configured") @@ -779,7 +741,7 @@ func (b *BackwardBeaconDownloader) fetchSingleEnvelope(ctx context.Context, bloc return nil, err } if resp.StatusCode == http.StatusNotFound { - return nil, nil // genuinely EMPTY: beacon API confirms no envelope + return nil, errExecutionPayloadEnvelopeNotFound } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("envelope fetch: HTTP %d", resp.StatusCode) diff --git a/cl/phase1/network/backward_beacon_downloader_test.go b/cl/phase1/network/backward_beacon_downloader_test.go index 166cf62894c..5358eabaa27 100644 --- a/cl/phase1/network/backward_beacon_downloader_test.go +++ b/cl/phase1/network/backward_beacon_downloader_test.go @@ -19,9 +19,11 @@ package network import ( "bytes" "context" + "errors" "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -272,7 +274,279 @@ func TestBackwardBeaconDownloaderFetchEnvelopeUsesRootAndRejectsIdentityMismatch require.Equal(t, "/eth/v1/beacon/execution_payload_envelopes/"+common.Hash(blockRoot).Hex(), <-requestedPath) } -func TestBackwardBeaconDownloaderRootFallbackAdvancesOnlyForEnvelopeOr404(t *testing.T) { +func TestBackwardBeaconDownloaderRequestMoreDoesNotFinishWhenMatchedCallbackFails(t *testing.T) { + cfg := gloasFromGenesisConfig() + target := makeGloasBlock(10, hash(0xaa), common.Hash{0x42}) + targetRoot, err := target.Block.HashSSZ() + require.NoError(t, err) + encodedBlock, err := target.EncodeSSZ(nil) + require.NoError(t, err) + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(cfg)} + envelope.Message.BeaconBlockRoot = targetRoot + encodedEnvelope, err := envelope.EncodeSSZ(nil) + require.NoError(t, err) + var httpRequests atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpRequests.Add(1) + switch { + case r.URL.Path == "/eth/v2/beacon/blocks/10": + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encodedBlock) + case strings.HasPrefix(r.URL.Path, "/eth/v1/beacon/execution_payload_envelopes/"): + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encodedEnvelope) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + downloader := &BackwardBeaconDownloader{ + expectedRoot: targetRoot, + httpFallbackURL: server.URL, + beaconCfg: cfg, + neverSkip: false, + reqInterval: time.NewTicker(time.Hour), + } + defer downloader.reqInterval.Stop() + downloader.httpPreferred.Store(true) + downloader.slotToDownload.Store(target.Block.Slot) + sentinelErr := errors.New("callback failed after deciding to finish") + failCallback := true + callbackAttempts := 0 + downloader.SetOnNewBlock(func(_ *cltypes.SignedBeaconBlock, _ *cltypes.SignedExecutionPayloadEnvelope) (bool, error) { + callbackAttempts++ + if failCallback { + return true, sentinelErr + } + return true, nil + }) + + require.NoError(t, downloader.RequestMore(t.Context())) + require.False(t, downloader.Finished()) + require.Equal(t, common.Hash(targetRoot), downloader.expectedRoot) + require.Equal(t, target.Block.Slot, downloader.Progress()) + require.Equal(t, 1, callbackAttempts) + require.False(t, downloader.httpPreferred.Load()) + requestsBeforeRetry := httpRequests.Load() + retryCtx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + require.ErrorIs(t, downloader.RequestMore(retryCtx), context.DeadlineExceeded) + require.Equal(t, requestsBeforeRetry, httpRequests.Load()) + require.Equal(t, 1, callbackAttempts) + + failCallback = false + downloader.httpPreferred.Store(true) + require.NoError(t, downloader.RequestMore(t.Context())) + require.Equal(t, 2, callbackAttempts) + require.True(t, downloader.Finished()) + require.Equal(t, target.Block.ParentRoot, downloader.expectedRoot) + require.Equal(t, target.Block.Slot-1, downloader.Progress()) +} + +func TestBackwardBeaconDownloaderRequestMoreDoesNotFinishWhenRootFallbackCallbackFails(t *testing.T) { + cfg := gloasFromGenesisConfig() + target := makeGloasBlock(10, hash(0xaa), common.Hash{0x42}) + targetRoot, err := target.Block.HashSSZ() + require.NoError(t, err) + wrong := makeGloasBlock(10, hash(0xbb), common.Hash{0x99}) + wrongRoot, err := wrong.Block.HashSSZ() + require.NoError(t, err) + encodedTarget, err := target.EncodeSSZ(nil) + require.NoError(t, err) + encodedWrong, err := wrong.EncodeSSZ(nil) + require.NoError(t, err) + encodedEnvelopes := make(map[string][]byte) + for _, root := range []common.Hash{targetRoot, wrongRoot} { + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(cfg)} + envelope.Message.BeaconBlockRoot = root + encoded, encodeErr := envelope.EncodeSSZ(nil) + require.NoError(t, encodeErr) + encodedEnvelopes[root.Hex()] = encoded + } + var httpRequests atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpRequests.Add(1) + switch { + case r.URL.Path == "/eth/v2/beacon/blocks/10": + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encodedWrong) + case r.URL.Path == "/eth/v2/beacon/blocks/"+common.Hash(targetRoot).Hex(): + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encodedTarget) + case strings.HasPrefix(r.URL.Path, "/eth/v1/beacon/execution_payload_envelopes/"): + root := strings.TrimPrefix(r.URL.Path, "/eth/v1/beacon/execution_payload_envelopes/") + encoded, ok := encodedEnvelopes[root] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encoded) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + downloader := &BackwardBeaconDownloader{ + expectedRoot: targetRoot, + httpFallbackURL: server.URL, + beaconCfg: cfg, + neverSkip: false, + reqInterval: time.NewTicker(time.Hour), + } + defer downloader.reqInterval.Stop() + downloader.httpPreferred.Store(true) + downloader.slotToDownload.Store(target.Block.Slot) + sentinelErr := errors.New("root callback failed after deciding to finish") + failCallback := true + callbackAttempts := 0 + downloader.SetOnNewBlock(func(_ *cltypes.SignedBeaconBlock, _ *cltypes.SignedExecutionPayloadEnvelope) (bool, error) { + callbackAttempts++ + if failCallback { + return true, sentinelErr + } + return true, nil + }) + + require.NoError(t, downloader.RequestMore(t.Context())) + require.False(t, downloader.Finished()) + require.Equal(t, common.Hash(targetRoot), downloader.expectedRoot) + require.Equal(t, target.Block.Slot, downloader.Progress()) + require.Equal(t, 1, callbackAttempts) + require.False(t, downloader.httpPreferred.Load()) + requestsBeforeRetry := httpRequests.Load() + retryCtx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + require.ErrorIs(t, downloader.RequestMore(retryCtx), context.DeadlineExceeded) + require.Equal(t, requestsBeforeRetry, httpRequests.Load()) + require.Equal(t, 1, callbackAttempts) + + failCallback = false + downloader.httpPreferred.Store(true) + require.NoError(t, downloader.RequestMore(t.Context())) + require.Equal(t, 2, callbackAttempts) + require.True(t, downloader.Finished()) + require.Equal(t, target.Block.ParentRoot, downloader.expectedRoot) + require.Equal(t, target.Block.Slot-1, downloader.Progress()) +} + +func TestBackwardBeaconDownloaderRequestMoreRetainsLookaheadAfterPartialCallbackFailure(t *testing.T) { + for _, full := range []bool{false, true} { + name := "empty" + if full { + name = "full" + } + t.Run(name, func(t *testing.T) { + cfg := gloasFromGenesisConfig() + lower := makeGloasBlock(100, hash(0xaa), common.Hash{0x42}) + parentBlockHash := hash(0xcc) + if full { + parentBlockHash = hash(0xaa) + } + higher := makeGloasBlock(101, hash(0xbb), parentBlockHash) + linkBeaconBlocks(t, lower, higher) + lowerRoot, err := lower.Block.HashSSZ() + require.NoError(t, err) + higherRoot, err := higher.Block.HashSSZ() + require.NoError(t, err) + encodedLower, err := lower.EncodeSSZ(nil) + require.NoError(t, err) + encodedHigher, err := higher.EncodeSSZ(nil) + require.NoError(t, err) + encodedEnvelopes := make(map[string][]byte) + for _, root := range []common.Hash{higherRoot, lowerRoot} { + envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(cfg)} + envelope.Message.BeaconBlockRoot = root + encoded, encodeErr := envelope.EncodeSSZ(nil) + require.NoError(t, encodeErr) + encodedEnvelopes[root.Hex()] = encoded + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/eth/v2/beacon/blocks/100": + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encodedLower) + case "/eth/v2/beacon/blocks/101": + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encodedHigher) + default: + prefix := "/eth/v1/beacon/execution_payload_envelopes/" + if !strings.HasPrefix(r.URL.Path, prefix) { + http.NotFound(w, r) + return + } + root := strings.TrimPrefix(r.URL.Path, prefix) + if !full && root == common.Hash(lowerRoot).Hex() { + http.NotFound(w, r) + return + } + encoded, ok := encodedEnvelopes[root] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encoded) + } + })) + defer server.Close() + + downloader := &BackwardBeaconDownloader{ + expectedRoot: higherRoot, + httpFallbackURL: server.URL, + beaconCfg: cfg, + neverSkip: false, + } + downloader.httpPreferred.Store(true) + downloader.slotToDownload.Store(higher.Block.Slot) + lowerAttempts := 0 + downloader.SetOnNewBlock(func(block *cltypes.SignedBeaconBlock, envelope *cltypes.SignedExecutionPayloadEnvelope) (bool, error) { + switch block.Block.Slot { + case higher.Block.Slot: + require.NotNil(t, envelope) + return false, nil + case lower.Block.Slot: + lowerAttempts++ + if full { + require.NotNil(t, envelope) + } else { + require.Nil(t, envelope) + } + if lowerAttempts == 1 { + return true, errors.New("lower callback failed") + } + return true, nil + default: + t.Fatalf("unexpected block slot %d", block.Block.Slot) + return false, nil + } + }) + + require.NoError(t, downloader.RequestMore(t.Context())) + require.False(t, downloader.Finished()) + require.Equal(t, common.Hash(lowerRoot), downloader.expectedRoot) + require.Equal(t, lower.Block.Slot, downloader.Progress()) + require.NotNil(t, downloader.prevBatchTopBlock) + retainedRoot, err := downloader.prevBatchTopBlock.Block.HashSSZ() + require.NoError(t, err) + require.Equal(t, higherRoot, retainedRoot) + + downloader.httpPreferred.Store(true) + require.NoError(t, downloader.RequestMore(t.Context())) + require.Equal(t, 2, lowerAttempts) + require.True(t, downloader.Finished()) + require.Equal(t, lower.Block.ParentRoot, downloader.expectedRoot) + require.Equal(t, lower.Block.Slot-1, downloader.Progress()) + }) + } +} + +func TestBackwardBeaconDownloaderRootFallbackAdvancesOnlyForEnvelope(t *testing.T) { cfg := gloasFromGenesisConfig() target := makeGloasBlock(10, hash(0xaa), common.Hash{0x42}) targetRoot, err := target.Block.HashSSZ() @@ -290,11 +564,10 @@ func TestBackwardBeaconDownloaderRootFallbackAdvancesOnlyForEnvelopeOr404(t *tes wantProcessed bool }{ { - name: "not found confirms empty", + name: "not found is not an empty proof", writeEnvelope: func(w http.ResponseWriter) { http.NotFound(w, nil) }, - wantProcessed: true, }, { name: "server error", @@ -340,7 +613,9 @@ func TestBackwardBeaconDownloaderRootFallbackAdvancesOnlyForEnvelopeOr404(t *tes for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + var httpRequests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpRequests.Add(1) switch { case strings.HasPrefix(r.URL.Path, "/eth/v2/beacon/blocks/"): w.Header().Set("Eth-Consensus-Version", "gloas") @@ -357,7 +632,10 @@ func TestBackwardBeaconDownloaderRootFallbackAdvancesOnlyForEnvelopeOr404(t *tes expectedRoot: targetRoot, httpFallbackURL: server.URL, beaconCfg: cfg, + reqInterval: time.NewTicker(time.Hour), } + defer downloader.reqInterval.Stop() + downloader.httpPreferred.Store(true) downloader.slotToDownload.Store(target.Block.Slot) processed := 0 downloader.SetOnNewBlock(func(_ *cltypes.SignedBeaconBlock, envelope *cltypes.SignedExecutionPayloadEnvelope) (bool, error) { @@ -375,6 +653,14 @@ func TestBackwardBeaconDownloaderRootFallbackAdvancesOnlyForEnvelopeOr404(t *tes require.Zero(t, processed) require.Equal(t, common.Hash(targetRoot), downloader.expectedRoot) require.Equal(t, target.Block.Slot, downloader.Progress()) + require.False(t, downloader.httpPreferred.Load()) + } + if tt.name == "not found is not an empty proof" { + requestsBeforeRetry := httpRequests.Load() + retryCtx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + require.ErrorIs(t, downloader.RequestMore(retryCtx), context.DeadlineExceeded) + require.Equal(t, requestsBeforeRetry, httpRequests.Load()) } }) } @@ -426,6 +712,7 @@ func TestBackwardBeaconDownloaderMissingRequiredEnvelopeSurvivesRestart(t *testi }) for range 3 { + downloader.httpPreferred.Store(true) require.NoError(t, downloader.processResponses(t.Context(), []*cltypes.SignedBeaconBlock{target, lookahead})) } require.Equal(t, 3, downloader.consecutiveEnvelopeFailures) @@ -458,7 +745,7 @@ func TestBackwardBeaconDownloaderMissingRequiredEnvelopeSurvivesRestart(t *testi require.Equal(t, target.Block.ParentRoot, restarted.expectedRoot) } -func TestBackwardBeaconDownloaderInitialEmptyAdvancesOnHTTP404(t *testing.T) { +func TestBackwardBeaconDownloaderInitial404RetainsUnresolvedBlock(t *testing.T) { cfg := gloasFromGenesisConfig() target := makeGloasBlock(10, hash(0xaa), common.Hash{0x42}) targetRoot, err := target.Block.HashSSZ() @@ -480,9 +767,59 @@ func TestBackwardBeaconDownloaderInitialEmptyAdvancesOnHTTP404(t *testing.T) { }) require.NoError(t, downloader.processResponses(t.Context(), []*cltypes.SignedBeaconBlock{target})) - require.Equal(t, 1, processed) - require.Equal(t, target.Block.ParentRoot, downloader.expectedRoot) - require.Equal(t, target.Block.Slot-1, downloader.Progress()) + require.Zero(t, processed) + require.Equal(t, common.Hash(targetRoot), downloader.expectedRoot) + require.Equal(t, target.Block.Slot, downloader.Progress()) +} + +func TestBackwardBeaconDownloaderPacesRepeatedUnresolvedEnvelopeRetry(t *testing.T) { + cfg := gloasFromGenesisConfig() + target := makeGloasBlock(10, hash(0xaa), common.Hash{0x42}) + targetRoot, err := target.Block.HashSSZ() + require.NoError(t, err) + encodedTarget, err := target.EncodeSSZ(nil) + require.NoError(t, err) + var envelopeRequests atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/eth/v2/beacon/blocks/10": + w.Header().Set("Eth-Consensus-Version", "gloas") + _, _ = w.Write(encodedTarget) + case strings.HasPrefix(r.URL.Path, "/eth/v1/beacon/execution_payload_envelopes/"): + envelopeRequests.Add(1) + http.NotFound(w, r) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + downloader := &BackwardBeaconDownloader{ + expectedRoot: targetRoot, + httpFallbackURL: server.URL, + beaconCfg: cfg, + neverSkip: false, + reqInterval: time.NewTicker(time.Hour), + } + defer downloader.reqInterval.Stop() + downloader.httpPreferred.Store(true) + downloader.slotToDownload.Store(target.Block.Slot) + downloader.SetOnNewBlock(func(_ *cltypes.SignedBeaconBlock, _ *cltypes.SignedExecutionPayloadEnvelope) (bool, error) { + t.Fatal("unresolved envelope must not reach callback") + return false, nil + }) + + require.NoError(t, downloader.RequestMore(t.Context())) + require.Equal(t, int32(1), envelopeRequests.Load()) + require.False(t, downloader.httpPreferred.Load()) + + retryCtx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + started := time.Now() + require.ErrorIs(t, downloader.RequestMore(retryCtx), context.DeadlineExceeded) + require.GreaterOrEqual(t, time.Since(started), 25*time.Millisecond) + require.Equal(t, int32(1), envelopeRequests.Load()) } func TestBackwardBeaconDownloaderProvenFullDoesNotAdvanceOnHTTP404(t *testing.T) { diff --git a/cl/phase1/network/beacon_downloader.go b/cl/phase1/network/beacon_downloader.go index 5a9386b3560..1b3dd3a3143 100644 --- a/cl/phase1/network/beacon_downloader.go +++ b/cl/phase1/network/beacon_downloader.go @@ -890,8 +890,7 @@ func validateHTTPBlockVersion(beaconCfg *clparams.BeaconChainConfig, slot uint64 } type fetchEnvelopeHTTPResult struct { - fetched int - notFound map[common.Hash]struct{} + fetched int } // fetchEnvelopesFromBeaconAPI fetches execution payload envelopes from the beacon API for FULL blocks missing from P2P. @@ -915,7 +914,6 @@ func fetchEnvelopesFromBeaconAPI( type envResult struct { hash common.Hash envelope *cltypes.SignedExecutionPayloadEnvelope - notFound bool } // Filter roots that need fetching @@ -971,7 +969,6 @@ func fetchEnvelopesFromBeaconAPI( return } if resp.StatusCode == http.StatusNotFound { - results[idx] = envResult{hash: common.Hash(root), notFound: true} return } if resp.StatusCode != http.StatusOK { @@ -997,15 +994,12 @@ func fetchEnvelopesFromBeaconAPI( } wg.Wait() - result := fetchEnvelopeHTTPResult{notFound: make(map[common.Hash]struct{})} + result := fetchEnvelopeHTTPResult{} for _, r := range results { if r.envelope != nil { received[r.hash] = r.envelope result.fetched++ } - if r.notFound { - result.notFound[r.hash] = struct{}{} - } } return result } diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index 37ab63c6e39..757e9b01624 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -1229,12 +1229,10 @@ func TestPublishedBlockJobConcurrentSchedulesReturnWaitableHandles(t *testing.T) handles := make(chan PublishedBlockJob, schedules) var wg sync.WaitGroup for range schedules { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { <-start handles <- service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil }) - }() + }) } close(start) wg.Wait() From 3e7a44dea36d93fb3cfd12546cf392c7bb298236 Mon Sep 17 00:00:00 2001 From: kewei Date: Fri, 28 Aug 2026 16:54:57 +0800 Subject: [PATCH 43/43] cl: make published job ordering deterministic --- cl/beacon/handler/epbs_test.go | 6 +- cl/phase1/network/services/block_service.go | 22 +++-- .../network/services/block_service_test.go | 88 ++++++++++++------- 3 files changed, 76 insertions(+), 40 deletions(-) diff --git a/cl/beacon/handler/epbs_test.go b/cl/beacon/handler/epbs_test.go index 898e0e2879f..676b067c014 100644 --- a/cl/beacon/handler/epbs_test.go +++ b/cl/beacon/handler/epbs_test.go @@ -47,6 +47,7 @@ import ( mock_services "github.com/erigontech/erigon/cl/phase1/network/services/mock_services" "github.com/erigontech/erigon/cl/pool" "github.com/erigontech/erigon/cl/utils/bls" + "github.com/erigontech/erigon/cl/utils/eth_clock" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" @@ -1687,7 +1688,10 @@ func TestGetValidatorExecutionPayloadEnvelopesBySlot(t *testing.T) { func TestGetValidatorExecutionPayloadEnvelopeByBlockRoot(t *testing.T) { _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true) handler.beaconChainCfg.GloasForkEpoch = 0 - slot := handler.ethClock.GetCurrentSlot() + slot := uint64(64) + clock := eth_clock.NewMockEthereumClock(gomock.NewController(t)) + clock.EXPECT().GetCurrentSlot().Return(slot).AnyTimes() + handler.ethClock = clock root := common.HexToHash("0x1234") envelope := cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg) envelope.BeaconBlockRoot = root diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go index dd735f5f1ab..957d9564889 100644 --- a/cl/phase1/network/services/block_service.go +++ b/cl/phase1/network/services/block_service.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "sync" + "sync/atomic" "time" "github.com/libp2p/go-libp2p/core/peer" @@ -48,14 +49,17 @@ var ErrInvalidSignature = errors.New("invalid signature") var ErrPublishedBlockJobExpired = errors.New("published block integration expired") var ErrPublishedBlockJobStopped = errors.New("block service stopped") +var publishedBlockJobSequence atomic.Uint64 + type proposerIndexAndSlot struct { proposerIndex uint64 slot uint64 } type blockJob struct { - block *cltypes.SignedBeaconBlock - creationTime time.Time + block *cltypes.SignedBeaconBlock + creationTime time.Time + scheduleSequence uint64 mu sync.Mutex store func(context.Context) error @@ -119,11 +123,12 @@ func newBlockJob(block *cltypes.SignedBeaconBlock, store func(context.Context) e generation = 1 } return &blockJob{ - block: block, - store: store, - storeGeneration: generation, - creationTime: time.Now(), - attempt: &blockJobAttempt{done: make(chan struct{})}, + block: block, + store: store, + storeGeneration: generation, + creationTime: time.Now(), + scheduleSequence: publishedBlockJobSequence.Add(1), + attempt: &blockJobAttempt{done: make(chan struct{})}, } } @@ -703,11 +708,12 @@ func (b *blockService) reuseScheduledBlockJob(key [32]byte, existing, job *block if store == nil { return existing, existing.storeGeneration } - if !job.creationTime.After(existing.creationTime) { + if job.scheduleSequence <= existing.scheduleSequence { return existing, existing.storeGeneration } existing.store = store existing.storeGeneration++ + existing.scheduleSequence = job.scheduleSequence existing.creationTime = time.Now() if existing.terminal { existing.terminal = false diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go index 757e9b01624..d45db29f2bf 100644 --- a/cl/phase1/network/services/block_service_test.go +++ b/cl/phase1/network/services/block_service_test.go @@ -519,6 +519,23 @@ func TestPublishedBlockJobUpgradesBlockOnlyRecovery(t *testing.T) { require.NotNil(t, jobValue.(*blockJob).store) } +func TestPublishedBlockJobUpgradeWithEqualCreationTime(t *testing.T) { + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + service := &blockService{} + existing := newBlockJob(block, nil) + candidate := newBlockJob(block, func(context.Context) error { return nil }) + candidate.creationTime = existing.creationTime + service.blocksScheduledForLaterExecution.Store(root, existing) + + reused, generation := service.reuseScheduledBlockJob(root, existing, candidate, candidate.store) + + require.Same(t, existing, reused) + require.Equal(t, uint64(1), generation) + require.NotNil(t, existing.store) +} + func TestPublishedBlockJobIsNotDowngradedByBlockOnlyRecovery(t *testing.T) { block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) root, err := block.Block.HashSSZ() @@ -537,18 +554,18 @@ func TestOlderPublishedBlockJobDoesNotReplaceNewerFullStore(t *testing.T) { block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion) root, err := block.Block.HashSSZ() require.NoError(t, err) - newer := &blockJob{ - block: block, - store: func(context.Context) error { return nil }, - creationTime: time.Now().Add(time.Minute), - } + older := newBlockJob(block, func(context.Context) error { + return errors.New("older store should not replace newer store") + }) + newer := newBlockJob(block, func(context.Context) error { return nil }) + older.creationTime = newer.creationTime service := &blockService{} service.blocksScheduledForLaterExecution.Store(root, newer) - service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { - return errors.New("older store should not replace newer store") - }) + reused, generation := service.reuseScheduledBlockJob(root, newer, older, older.store) + require.Same(t, newer, reused) + require.Equal(t, newer.storeGeneration, generation) currentValue, ok := service.blocksScheduledForLaterExecution.Load(root) require.True(t, ok) require.Same(t, newer, currentValue) @@ -1122,30 +1139,15 @@ func TestPublishedBlockJobExpiryRescheduleKeepsFreshStore(t *testing.T) { require.NoError(t, err) expiredHandle := service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil }) job := serviceJob(t, service, root) - job.mu.Lock() - expireStarted := make(chan struct{}) - expireDone := make(chan struct{}) - go func() { - close(expireStarted) - service.processScheduledBlock(context.Background(), root, job, job.creationTime.Add(blockJobExpiry+time.Second)) - close(expireDone) - }() - <-expireStarted - freshStoreCalls := 0 - rescheduleStarted := make(chan struct{}) - rescheduleDone := make(chan PublishedBlockJob, 1) - go func() { - close(rescheduleStarted) - rescheduleDone <- service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { - freshStoreCalls++ - return nil - }) - }() - <-rescheduleStarted - job.mu.Unlock() - <-expireDone + job.creationTime = time.Now().Add(-blockJobExpiry - time.Second) + service.processScheduledBlock(context.Background(), root, job, time.Now()) require.ErrorIs(t, expiredHandle.Wait(t.Context()), ErrPublishedBlockJobExpired) - freshHandle := <-rescheduleDone + + freshStoreCalls := 0 + freshHandle := service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + freshStoreCalls++ + return nil + }) freshJob := serviceJob(t, service, root) require.NotSame(t, job, freshJob) service.processScheduledBlock(context.Background(), root, freshJob, time.Now()) @@ -1153,6 +1155,30 @@ func TestPublishedBlockJobExpiryRescheduleKeepsFreshStore(t *testing.T) { require.Equal(t, 1, freshStoreCalls) } +func TestPublishedBlockJobRefreshSurvivesEarlierExpirySample(t *testing.T) { + service := &blockService{} + block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version) + root, err := block.Block.HashSSZ() + require.NoError(t, err) + firstHandle := service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil }) + job := serviceJob(t, service, root) + job.creationTime = time.Now().Add(-blockJobExpiry - time.Second) + expiryNow := time.Now() + freshStoreCalls := 0 + freshHandle := service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { + freshStoreCalls++ + return nil + }) + + service.processScheduledBlock(context.Background(), root, job, expiryNow) + + require.NoError(t, firstHandle.Wait(t.Context())) + require.NoError(t, freshHandle.Wait(t.Context())) + require.Equal(t, 1, freshStoreCalls) + _, scheduled := service.blocksScheduledForLaterExecution.Load(root) + require.False(t, scheduled) +} + func TestPublishedBlockJobShutdownClosesQueuedWaitersAndRejectsNewSchedules(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cfg := clparams.MainnetBeaconConfig