Skip to content

Commit 3ec49a3

Browse files
authored
cl/gloas: gate fork choice on verified execution payloads (erigontech#21417)
## Summary - Replace `HasEnvelope` (disk existence) with `verifiedExecutionPayload` (EL returned VALID) in GLOAS fork choice logic - Add `MarkPayloadVerified` / `IsPayloadVerified` / `RequeuePendingELPayload` on `ForkChoiceStore` - Change local-EL drain path in `chainTipSync` from `InsertBlocks` to per-payload `NewPayload`: VALID -> mark verified, SYNCING/ACCEPTED -> re-queue - Add a bounded verification sweep at chain tip for unverified blocks between finalized and head - Gate local-EL retry/sweep machinery by `SupportInsertion()` so standalone Caplin with a remote EL is unaffected - Add stages coverage for anchor envelope validation, builder/self-build signatures, pending payload retry helpers, and standalone gating Closes erigontech#21131 ## Test plan - [x] `go test ./cl/phase1/forkchoice/... -count=1` - [x] `go test ./cl/phase1/stages -count=1` - [x] `go test ./cl/phase1/network/services -count=1` - [x] `go test -tags=spectest ./cl/spectest -run '^$' -count=1` - [x] `make lint` - [x] `make erigon integration`
1 parent e185784 commit 3ec49a3

17 files changed

Lines changed: 1451 additions & 79 deletions

cl/phase1/forkchoice/forkchoice.go

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ type ForkChoiceStore struct {
107107
// [New in Gloas:EIP7732] Track execution payload validation status by execution block hash.
108108
// Used to check if parent execution payload has been validated/invalidated for gossip validation.
109109
executionPayloadStatus *lru.Cache[common.Hash, execution_client.PayloadStatus]
110+
payloadStatusByRoot *lru.Cache[common.Hash, execution_client.PayloadStatus]
110111
// [New in Gloas:EIP7732] Track execution payload gas_limit by execution block hash.
111112
// Used for the is_gas_limit_target_compatible IGNORE check in bid gossip validation.
112113
executionPayloadGasLimit *lru.Cache[common.Hash, uint64]
@@ -316,6 +317,10 @@ func NewForkChoiceStore(
316317
if err != nil {
317318
return nil, err
318319
}
320+
payloadStatusByRoot, err := lru.New[common.Hash, execution_client.PayloadStatus](checkpointsPerCache)
321+
if err != nil {
322+
return nil, err
323+
}
319324

320325
// [New in Gloas:EIP7732] Track execution payload gas_limit by execution block hash
321326
executionPayloadGasLimit, err := lru.New[common.Hash, uint64](checkpointsPerCache)
@@ -377,6 +382,7 @@ func NewForkChoiceStore(
377382
pendingEnvelopes: pendingEnvelopes,
378383
pendingLocalSelfBuildEnvelopes: pendingLocalSelfBuildEnvelopes,
379384
executionPayloadStatus: executionPayloadStatus,
385+
payloadStatusByRoot: payloadStatusByRoot,
380386
executionPayloadGasLimit: executionPayloadGasLimit,
381387
db: db,
382388
}
@@ -427,6 +433,13 @@ func (f *ForkChoiceStore) GetRecentExecutionPayloadStatus(executionBlockHash com
427433
return f.executionPayloadStatus.Get(executionBlockHash)
428434
}
429435

436+
func (f *ForkChoiceStore) GetRecentExecutionPayloadStatusByRoot(blockRoot common.Hash) (execution_client.PayloadStatus, bool) {
437+
if f.payloadStatusByRoot == nil {
438+
return execution_client.PayloadStatusNone, false
439+
}
440+
return f.payloadStatusByRoot.Get(blockRoot)
441+
}
442+
430443
// GetExecutionPayloadGasLimit returns the gas_limit of a recently validated execution payload.
431444
func (f *ForkChoiceStore) GetExecutionPayloadGasLimit(executionBlockHash common.Hash) (uint64, bool) {
432445
return f.executionPayloadGasLimit.Get(executionBlockHash)
@@ -761,6 +774,58 @@ func (f *ForkChoiceStore) HasEnvelope(blockRoot common.Hash) bool {
761774
return f.forkGraph.HasEnvelope(blockRoot)
762775
}
763776

777+
// IsPayloadVerified returns whether the execution payload for the beacon block root
778+
// has been accepted by the execution layer.
779+
// [New in Gloas:EIP7732]
780+
func (f *ForkChoiceStore) IsPayloadVerified(blockRoot common.Hash) bool {
781+
if f.verifiedExecutionPayload == nil {
782+
return false
783+
}
784+
return f.verifiedExecutionPayload.Contains(blockRoot)
785+
}
786+
787+
func (f *ForkChoiceStore) MarkPayloadVerified(blockRoot common.Hash, executionBlockHash common.Hash) {
788+
f.mu.Lock()
789+
defer f.mu.Unlock()
790+
f.markPayloadVerifiedLocked(blockRoot, executionBlockHash)
791+
}
792+
793+
func (f *ForkChoiceStore) markPayloadVerifiedLocked(blockRoot common.Hash, executionBlockHash common.Hash) {
794+
if f.verifiedExecutionPayload == nil {
795+
return
796+
}
797+
f.verifiedExecutionPayload.Add(blockRoot, struct{}{})
798+
if f.executionPayloadStatus != nil {
799+
f.executionPayloadStatus.Add(executionBlockHash, execution_client.PayloadStatusValidated)
800+
}
801+
if f.payloadStatusByRoot != nil {
802+
f.payloadStatusByRoot.Add(blockRoot, execution_client.PayloadStatusValidated)
803+
}
804+
f.headHash = common.Hash{}
805+
f.headPayloadStatus = cltypes.PayloadStatusPending
806+
}
807+
808+
func (f *ForkChoiceStore) MarkPayloadInvalid(blockRoot common.Hash, executionBlockHash common.Hash) {
809+
f.mu.Lock()
810+
defer f.mu.Unlock()
811+
f.markPayloadInvalidLocked(blockRoot, executionBlockHash)
812+
}
813+
814+
func (f *ForkChoiceStore) markPayloadInvalidLocked(blockRoot common.Hash, executionBlockHash common.Hash) {
815+
if f.verifiedExecutionPayload != nil {
816+
f.verifiedExecutionPayload.Remove(blockRoot)
817+
}
818+
if f.executionPayloadStatus != nil {
819+
f.executionPayloadStatus.Add(executionBlockHash, execution_client.PayloadStatusInvalidated)
820+
}
821+
if f.payloadStatusByRoot != nil {
822+
f.payloadStatusByRoot.Add(blockRoot, execution_client.PayloadStatusInvalidated)
823+
}
824+
f.forkGraph.MarkHeaderAsInvalid(blockRoot)
825+
f.headHash = common.Hash{}
826+
f.headPayloadStatus = cltypes.PayloadStatusPending
827+
}
828+
764829
// ReadEnvelopeFromDisk delegates to forkGraph.ReadEnvelopeFromDisk.
765830
// [New in Gloas:EIP7732]
766831
func (f *ForkChoiceStore) ReadEnvelopeFromDisk(blockRoot common.Hash) (*cltypes.SignedExecutionPayloadEnvelope, error) {
@@ -985,6 +1050,14 @@ func (f *ForkChoiceStore) GetProposerLookahead(slot uint64) (solid.Uint64VectorS
9851050
func (f *ForkChoiceStore) addPendingELPayload(block *cltypes.SignedBeaconBlock, envelope *cltypes.SignedExecutionPayloadEnvelope) {
9861051
f.pendingELPayloadsMu.Lock()
9871052
defer f.pendingELPayloadsMu.Unlock()
1053+
root, ok := pendingELPayloadRoot(PendingELPayload{Block: block, Envelope: envelope})
1054+
if ok {
1055+
for _, p := range f.pendingELPayloads {
1056+
if existingRoot, existingOk := pendingELPayloadRoot(p); existingOk && existingRoot == root {
1057+
return
1058+
}
1059+
}
1060+
}
9881061
if len(f.pendingELPayloads) >= maxPendingELPayloads {
9891062
log.Warn("addPendingELPayload: dropping oldest pending EL payload", "queueLen", len(f.pendingELPayloads))
9901063
copy(f.pendingELPayloads, f.pendingELPayloads[1:])
@@ -997,8 +1070,21 @@ func (f *ForkChoiceStore) addPendingELPayload(block *cltypes.SignedBeaconBlock,
9971070
})
9981071
}
9991072

1073+
func pendingELPayloadRoot(p PendingELPayload) (common.Hash, bool) {
1074+
if p.Envelope != nil && p.Envelope.Message != nil && p.Envelope.Message.BeaconBlockRoot != (common.Hash{}) {
1075+
return p.Envelope.Message.BeaconBlockRoot, true
1076+
}
1077+
return common.Hash{}, false
1078+
}
1079+
1080+
// RequeuePendingELPayload queues a drained execution payload for another EL validation attempt.
1081+
// [New in Gloas:EIP7732]
1082+
func (f *ForkChoiceStore) RequeuePendingELPayload(p PendingELPayload) {
1083+
f.addPendingELPayload(p.Block, p.Envelope)
1084+
}
1085+
10001086
// DrainPendingELPayloads returns and clears all queued EL payloads.
1001-
// The stages layer calls this before Flush() to add them to blockCollector.
1087+
// The stages layer calls this before Flush() to retry them with engine.NewPayload.
10021088
func (f *ForkChoiceStore) DrainPendingELPayloads() []PendingELPayload {
10031089
f.pendingELPayloadsMu.Lock()
10041090
defer f.pendingELPayloadsMu.Unlock()

cl/phase1/forkchoice/interface.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,11 @@ type ForkChoiceStorageReader interface {
7575
GetBlock(blockRoot common.Hash) (*cltypes.SignedBeaconBlock, bool)
7676
// [New in Gloas:EIP7732] HasEnvelope checks if a signed execution payload envelope exists.
7777
HasEnvelope(blockRoot common.Hash) bool
78+
// [New in Gloas:EIP7732] IsPayloadVerified checks whether the execution payload was accepted by the EL.
79+
IsPayloadVerified(blockRoot common.Hash) bool
7880
// [New in Gloas:EIP7732] ReadEnvelopeFromDisk reads a signed execution payload envelope from disk.
7981
ReadEnvelopeFromDisk(blockRoot common.Hash) (*cltypes.SignedExecutionPayloadEnvelope, error)
82+
GetRecentExecutionPayloadStatusByRoot(blockRoot common.Hash) (execution_client.PayloadStatus, bool)
8083
// [New in Gloas:EIP7732] IsBlobDataAvailable returns the local node's assessment of whether
8184
// blob data is available for the given block. Used by the payload_attestation_data API so PTC
8285
// validators can set the blob_data_available flag independently of payload_present.

cl/phase1/forkchoice/mock_services/forkchoice_mock.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ type ForkChoiceStorageMock struct {
6969
Headers map[common.Hash]*cltypes.BeaconBlockHeader
7070
Blocks map[common.Hash]*cltypes.SignedBeaconBlock
7171
Envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope
72+
VerifiedPayloads map[common.Hash]bool
7273
GetBeaconCommitteeMock func(slot, committeeIndex uint64) ([]uint64, error)
7374

7475
Pool pool.OperationsPool
@@ -82,6 +83,7 @@ type ForkChoiceStorageMock struct {
8283

8384
// [New in Gloas:EIP7732] Execution payload status by execution block hash
8485
ExecutionPayloadStatusMap map[common.Hash]execution_client.PayloadStatus
86+
PayloadStatusByRootMap map[common.Hash]execution_client.PayloadStatus
8587
// [New in Gloas:EIP7732] Execution payload gas limit by execution block hash
8688
ExecutionPayloadGasLimitMap map[common.Hash]uint64
8789
}
@@ -213,6 +215,7 @@ func NewForkChoiceStorageMock(t *testing.T) *ForkChoiceStorageMock {
213215
SyncContributionPool: makeSyncContributionPoolMock(t),
214216
MockPeerDas: mockPeerDas,
215217
ExecutionPayloadStatusMap: make(map[common.Hash]execution_client.PayloadStatus),
218+
PayloadStatusByRootMap: make(map[common.Hash]execution_client.PayloadStatus),
216219
ExecutionPayloadGasLimitMap: make(map[common.Hash]uint64),
217220
}
218221
}
@@ -426,6 +429,13 @@ func (f *ForkChoiceStorageMock) HasEnvelope(blockRoot common.Hash) bool {
426429
return ok
427430
}
428431

432+
func (f *ForkChoiceStorageMock) IsPayloadVerified(blockRoot common.Hash) bool {
433+
if f.VerifiedPayloads == nil {
434+
return false
435+
}
436+
return f.VerifiedPayloads[blockRoot]
437+
}
438+
429439
func (f *ForkChoiceStorageMock) ReadEnvelopeFromDisk(blockRoot common.Hash) (*cltypes.SignedExecutionPayloadEnvelope, error) {
430440
return f.Envelopes[blockRoot], nil
431441
}
@@ -530,6 +540,11 @@ func (f *ForkChoiceStorageMock) GetRecentExecutionPayloadStatus(executionBlockHa
530540
return status, ok
531541
}
532542

543+
func (f *ForkChoiceStorageMock) GetRecentExecutionPayloadStatusByRoot(blockRoot common.Hash) (execution_client.PayloadStatus, bool) {
544+
status, ok := f.PayloadStatusByRootMap[blockRoot]
545+
return status, ok
546+
}
547+
533548
// GetExecutionPayloadGasLimit returns the gas_limit of a recently validated execution payload.
534549
// [New in Gloas:EIP7732]
535550
func (f *ForkChoiceStorageMock) GetExecutionPayloadGasLimit(executionBlockHash common.Hash) (uint64, bool) {

cl/phase1/forkchoice/on_attestation.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ func (f *ForkChoiceStore) ValidateOnAttestation(attestation *solid.Attestation)
282282
return errors.New("attestation index must be 0 when block_slot equals attestation_slot")
283283
}
284284
// PTC attestation (index 1): payload must be verified
285-
if attestation.Data.CommitteeIndex == 1 && !f.forkGraph.HasEnvelope(attestation.Data.BeaconBlockRoot) {
285+
if attestation.Data.CommitteeIndex == 1 && !f.IsPayloadVerified(attestation.Data.BeaconBlockRoot) {
286286
return errors.New("PTC attestation requires verified payload envelope")
287287
}
288288
}

cl/phase1/forkchoice/on_execution_payload.go

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -296,19 +296,11 @@ func (f *ForkChoiceStore) validatePayloadWithEL(
296296
}
297297
case execution_client.PayloadStatusInvalidated:
298298
log.Warn("validatePayloadWithEL: payload is invalid", "beaconBlockRoot", beaconBlockRoot, "err", err)
299-
f.forkGraph.MarkHeaderAsInvalid(beaconBlockRoot)
300-
// remove from optimistic candidate
301-
if err := f.optimisticStore.InvalidateBlock(beaconBlockRoot, block.Block); err != nil {
302-
return fmt.Errorf("failed to remove block from optimistic store: %v", err)
303-
}
299+
f.markPayloadInvalidLocked(beaconBlockRoot, executionBlockHash)
304300
return errors.New("execution payload is invalid")
305301
case execution_client.PayloadStatusValidated:
306302
log.Trace("validatePayloadWithEL: payload is validated", "beaconBlockRoot", beaconBlockRoot)
307-
// remove from optimistic candidate
308-
if err := f.optimisticStore.ValidateBlock(beaconBlockRoot, block.Block); err != nil {
309-
return fmt.Errorf("failed to validate block in optimistic store: %v", err)
310-
}
311-
f.verifiedExecutionPayload.Add(beaconBlockRoot, struct{}{})
303+
f.markPayloadVerifiedLocked(beaconBlockRoot, executionBlockHash)
312304
}
313305

314306
if err != nil {
@@ -451,24 +443,24 @@ func (f *ForkChoiceStore) applyEnvelopeLocked(ctx context.Context, signedEnvelop
451443
// on disk to resolve parent execution payloads for subsequent blocks.
452444
// [New in Gloas:EIP7732]
453445
func (f *ForkChoiceStore) StoreAnchorEnvelope(blockRoot common.Hash, signedEnvelope *cltypes.SignedExecutionPayloadEnvelope) error {
454-
if signedEnvelope == nil || signedEnvelope.Message == nil {
446+
if signedEnvelope == nil || signedEnvelope.Message == nil || signedEnvelope.Message.Payload == nil {
455447
return errors.New("StoreAnchorEnvelope: nil envelope")
456448
}
457449
envelope := signedEnvelope.Message
450+
if envelope.BeaconBlockRoot != blockRoot {
451+
return fmt.Errorf("StoreAnchorEnvelope: envelope root %v does not match block root %v", envelope.BeaconBlockRoot, blockRoot)
452+
}
458453

459454
f.mu.Lock()
460-
// Update eth2Roots mapping so FCU can resolve the EL block hash
461-
if envelope.Payload != nil {
462-
f.eth2Roots.Add(blockRoot, envelope.Payload.BlockHash)
463-
}
464-
// Persist to disk so HasEnvelope() returns true and forward sync can find it
465455
if err := f.forkGraph.DumpEnvelopeOnDisk(blockRoot, signedEnvelope); err != nil {
466456
f.mu.Unlock()
467457
return fmt.Errorf("StoreAnchorEnvelope: failed to dump envelope: %w", err)
468458
}
459+
f.eth2Roots.Add(blockRoot, envelope.Payload.BlockHash)
460+
f.headHash = common.Hash{}
461+
f.headPayloadStatus = cltypes.PayloadStatusPending
469462
f.mu.Unlock()
470463

471-
// Write DB indices outside the lock
472464
if f.db != nil {
473465
ctx := context.Background()
474466
if err := f.db.Update(ctx, func(tx kv.RwTx) error {
@@ -477,6 +469,7 @@ func (f *ForkChoiceStore) StoreAnchorEnvelope(blockRoot common.Hash, signedEnvel
477469
return fmt.Errorf("StoreAnchorEnvelope: failed to write indices: %w", err)
478470
}
479471
}
472+
480473
return nil
481474
}
482475

cl/phase1/forkchoice/on_execution_payload_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@ package forkchoice
1919
import (
2020
"context"
2121
"testing"
22+
"time"
2223

24+
"github.com/hashicorp/golang-lru/v2"
2325
"github.com/stretchr/testify/require"
26+
"go.uber.org/mock/gomock"
2427

2528
"github.com/erigontech/erigon/cl/clparams"
2629
"github.com/erigontech/erigon/cl/cltypes"
2730
"github.com/erigontech/erigon/cl/cltypes/solid"
31+
"github.com/erigontech/erigon/cl/phase1/execution_client"
2832
"github.com/erigontech/erigon/common"
2933
)
3034

@@ -271,3 +275,90 @@ func TestValidatePayloadWithEL_NoEngine(t *testing.T) {
271275
err := f.validatePayloadWithEL(context.TODO(), envelope, block, common.Hash{})
272276
require.NoError(t, err)
273277
}
278+
279+
func TestValidatePayloadWithELDoesNotRelockForkChoiceMu(t *testing.T) {
280+
cfg := &clparams.MainnetBeaconConfig
281+
for _, tt := range []struct {
282+
name string
283+
status execution_client.PayloadStatus
284+
wantErr bool
285+
wantVerify bool
286+
}{
287+
{
288+
name: "validated",
289+
status: execution_client.PayloadStatusValidated,
290+
wantVerify: true,
291+
},
292+
{
293+
name: "invalidated",
294+
status: execution_client.PayloadStatusInvalidated,
295+
wantErr: true,
296+
},
297+
} {
298+
t.Run(tt.name, func(t *testing.T) {
299+
ctrl := gomock.NewController(t)
300+
engine := execution_client.NewMockExecutionEngine(ctrl)
301+
engine.EXPECT().
302+
NewPayload(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
303+
Return(tt.status, nil)
304+
305+
verifiedExecutionPayload, err := lru.New[common.Hash, struct{}](16)
306+
require.NoError(t, err)
307+
executionPayloadStatus, err := lru.New[common.Hash, execution_client.PayloadStatus](16)
308+
require.NoError(t, err)
309+
payloadStatusByRoot, err := lru.New[common.Hash, execution_client.PayloadStatus](16)
310+
require.NoError(t, err)
311+
executionPayloadGasLimit, err := lru.New[common.Hash, uint64](16)
312+
require.NoError(t, err)
313+
314+
blockRoot := common.HexToHash("0x1234")
315+
executionBlockHash := common.HexToHash("0xabcd")
316+
invalidatedHeader := common.Hash{}
317+
f := &ForkChoiceStore{
318+
beaconCfg: cfg,
319+
engine: engine,
320+
forkGraph: payloadVoteForkGraph{invalidatedHeader: &invalidatedHeader},
321+
verifiedExecutionPayload: verifiedExecutionPayload,
322+
executionPayloadStatus: executionPayloadStatus,
323+
payloadStatusByRoot: payloadStatusByRoot,
324+
executionPayloadGasLimit: executionPayloadGasLimit,
325+
}
326+
envelope := &cltypes.ExecutionPayloadEnvelope{
327+
Payload: &cltypes.Eth1Block{BlockHash: executionBlockHash},
328+
}
329+
body := cltypes.NewBeaconBody(cfg, clparams.GloasVersion)
330+
body.SignedExecutionPayloadBid = &cltypes.SignedExecutionPayloadBid{
331+
Message: &cltypes.ExecutionPayloadBid{
332+
BlobKzgCommitments: *solid.NewStaticListSSZ[*cltypes.KZGCommitment](0, 48),
333+
},
334+
}
335+
block := &cltypes.SignedBeaconBlock{
336+
Block: &cltypes.BeaconBlock{
337+
Body: body,
338+
},
339+
}
340+
341+
done := make(chan error, 1)
342+
go func() {
343+
f.mu.Lock()
344+
defer f.mu.Unlock()
345+
done <- f.validatePayloadWithEL(context.Background(), envelope, block, blockRoot)
346+
}()
347+
348+
select {
349+
case err := <-done:
350+
if tt.wantErr {
351+
require.Error(t, err)
352+
} else {
353+
require.NoError(t, err)
354+
}
355+
case <-time.After(time.Second):
356+
t.Fatal("validatePayloadWithEL blocked while forkchoice mutex was already held")
357+
}
358+
require.Equal(t, tt.wantVerify, f.IsPayloadVerified(blockRoot))
359+
if tt.status == execution_client.PayloadStatusInvalidated {
360+
require.Equal(t, blockRoot, invalidatedHeader)
361+
}
362+
})
363+
}
364+
}

0 commit comments

Comments
 (0)