diff --git a/beacon-chain/blockchain/chain_info.go b/beacon-chain/blockchain/chain_info.go index 234099fd303c..f023b56ad578 100644 --- a/beacon-chain/blockchain/chain_info.go +++ b/beacon-chain/blockchain/chain_info.go @@ -32,6 +32,14 @@ type ChainInfoFetcher interface { ForkFetcher HeadDomainFetcher ForkchoiceFetcher + PayloadAvailabilityFetcher +} + +// PayloadAvailabilityFetcher provides the payload arrival and blob data availability status +// used to build payload attestations. +type PayloadAvailabilityFetcher interface { + PayloadEarly([32]byte) (bool, bool) + DataAvailable(context.Context, [32]byte, primitives.Slot) (bool, error) } // ForkchoiceFetcher defines a common interface for methods that access directly @@ -41,6 +49,7 @@ type ChainInfoFetcher interface { type ForkchoiceFetcher interface { Ancestor(context.Context, []byte, primitives.Slot) ([]byte, error) BlockHash(root [32]byte) ([32]byte, error) + HasPayloadBlockHash(root, blockHash [32]byte) bool GasLimit(root [32]byte) (uint64, error) CachedHeadRoot() [32]byte GetProposerHead() [32]byte @@ -50,7 +59,6 @@ type ForkchoiceFetcher interface { HighestReceivedBlockRoot() [32]byte HasNode([32]byte) bool HasFullNode([32]byte) bool - PayloadEarly([32]byte) (bool, bool) FullBeatsEmpty([32]byte) bool ReceivedBlocksLastEpoch() (uint64, error) InsertNode(context.Context, state.BeaconState, consensus_blocks.ROBlock) error diff --git a/beacon-chain/blockchain/chain_info_forkchoice.go b/beacon-chain/blockchain/chain_info_forkchoice.go index d0e32aa34cc6..6910df319db5 100644 --- a/beacon-chain/blockchain/chain_info_forkchoice.go +++ b/beacon-chain/blockchain/chain_info_forkchoice.go @@ -56,6 +56,13 @@ func (s *Service) BlockHash(root [32]byte) ([32]byte, error) { return s.cfg.ForkChoiceStore.BlockHash(root) } +// HasPayloadBlockHash reports whether blockHash is an available payload parent at root. +func (s *Service) HasPayloadBlockHash(root, blockHash [32]byte) bool { + s.cfg.ForkChoiceStore.RLock() + defer s.cfg.ForkChoiceStore.RUnlock() + return s.cfg.ForkChoiceStore.HasPayloadBlockHash(root, blockHash) +} + // GasLimit returns the gas limit of the latest full payload at or before the given beacon block root from forkchoice. func (s *Service) GasLimit(root [32]byte) (uint64, error) { s.cfg.ForkChoiceStore.RLock() diff --git a/beacon-chain/blockchain/receive_execution_payload_envelope.go b/beacon-chain/blockchain/receive_execution_payload_envelope.go index 9130aec0ff17..ba9633663799 100644 --- a/beacon-chain/blockchain/receive_execution_payload_envelope.go +++ b/beacon-chain/blockchain/receive_execution_payload_envelope.go @@ -275,9 +275,11 @@ func (s *Service) callNewPayload( ) (bool, error) { _, err := s.cfg.ExecutionEngineCaller.NewPayload(ctx, payload, versionedHashes, &parentRoot, requests) if err == nil { + newPayloadValidNodeCount.Inc() return true, nil } if errors.Is(err, execution.ErrAcceptedSyncingPayloadStatus) { + newPayloadOptimisticNodeCount.Inc() log.WithFields(logrus.Fields{ "slot": slot, "payloadBlockHash": fmt.Sprintf("%#x", bytesutil.Trunc(payload.BlockHash())), @@ -285,6 +287,7 @@ func (s *Service) callNewPayload( return false, nil } if errors.Is(err, execution.ErrInvalidPayloadStatus) { + newPayloadInvalidNodeCount.Inc() return false, invalidBlock{error: ErrInvalidPayload} } return false, errors.WithMessage(ErrUndefinedExecutionEngineError, err.Error()) @@ -378,6 +381,35 @@ func (s *Service) PayloadEarly(root [32]byte) (bool, bool) { return s.payloadArrivals.isEarly(root) } +// DataAvailable reports whether all blob data committed to by the block at root is available now. +func (s *Service) DataAvailable(ctx context.Context, root [32]byte, slot primitives.Slot) (bool, error) { + available, err := s.dataColumnsAvailableNow(ctx, root, slot) + if err != nil { + return false, errors.Wrap(err, "data columns available now") + } + if available { + return true, nil + } + + s.headLock.RLock() + var b interfaces.ReadOnlySignedBeaconBlock + if s.head != nil && s.head.root == root { + b = s.head.block + } + s.headLock.RUnlock() + if b == nil { + b, err = s.getBlock(ctx, root) + if err != nil { + return false, errors.Wrap(err, "could not get block") + } + } + sbid, err := b.Block().Body().SignedExecutionPayloadBid() + if err != nil { + return false, errors.Wrap(err, "could not get signed execution payload bid from block") + } + return len(sbid.GetMessage().GetBlobKzgCommitments()) == 0, nil +} + // notifyForkchoiceUpdateGloas takes the block hash directly because Gloas // blocks don't carry an execution payload in the body. func (s *Service) notifyForkchoiceUpdateGloas(ctx context.Context, blockHash [32]byte, attributes payloadattribute.Attributer) (*enginev1.PayloadIDBytes, error) { diff --git a/beacon-chain/blockchain/receive_execution_payload_envelope_test.go b/beacon-chain/blockchain/receive_execution_payload_envelope_test.go index 7a69efd5016f..ed8fcd6f566c 100644 --- a/beacon-chain/blockchain/receive_execution_payload_envelope_test.go +++ b/beacon-chain/blockchain/receive_execution_payload_envelope_test.go @@ -7,6 +7,7 @@ import ( "github.com/OffchainLabs/prysm/v7/beacon-chain/core/feed" statefeed "github.com/OffchainLabs/prysm/v7/beacon-chain/core/feed/state" "github.com/OffchainLabs/prysm/v7/beacon-chain/core/signing" + "github.com/OffchainLabs/prysm/v7/beacon-chain/db/filesystem" "github.com/OffchainLabs/prysm/v7/beacon-chain/execution" mockExecution "github.com/OffchainLabs/prysm/v7/beacon-chain/execution/testing" state_native "github.com/OffchainLabs/prysm/v7/beacon-chain/state/state-native" @@ -20,6 +21,7 @@ import ( ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1" "github.com/OffchainLabs/prysm/v7/runtime/version" "github.com/OffchainLabs/prysm/v7/testing/require" + "github.com/OffchainLabs/prysm/v7/testing/util" "github.com/OffchainLabs/prysm/v7/time/slots" ) @@ -248,6 +250,39 @@ func TestReceiveExecutionPayloadEnvelope_EmitsHeadV2Event(t *testing.T) { }) } +func TestDataAvailable(t *testing.T) { + saveGloasBlock := func(t *testing.T, service *Service, commitments [][]byte) [32]byte { + b := util.NewBeaconBlockGloas() + b.Block.Body.SignedExecutionPayloadBid.Message.BlobKzgCommitments = commitments + sb, err := blocks.NewSignedBeaconBlock(b) + require.NoError(t, err) + root, err := sb.Block().HashTreeRoot() + require.NoError(t, err) + require.NoError(t, service.cfg.BeaconDB.SaveBlock(t.Context(), sb)) + return root + } + + t.Run("unknown block returns error", func(t *testing.T) { + service, _ := minimalTestService(t, WithDataColumnStorage(filesystem.NewEphemeralDataColumnStorage(t))) + _, err := service.DataAvailable(t.Context(), [32]byte{'a'}, 0) + require.NotNil(t, err) + }) + t.Run("no blob commitments", func(t *testing.T) { + service, _ := minimalTestService(t, WithDataColumnStorage(filesystem.NewEphemeralDataColumnStorage(t))) + root := saveGloasBlock(t, service, nil) + available, err := service.DataAvailable(t.Context(), root, 0) + require.NoError(t, err) + require.Equal(t, true, available) + }) + t.Run("commitments with no columns stored", func(t *testing.T) { + service, _ := minimalTestService(t, WithDataColumnStorage(filesystem.NewEphemeralDataColumnStorage(t))) + root := saveGloasBlock(t, service, [][]byte{bytesutil.PadTo([]byte{0x01}, 48)}) + available, err := service.DataAvailable(t.Context(), root, 0) + require.NoError(t, err) + require.Equal(t, false, available) + }) +} + // countStateEventsByType is a helper function for counting the number of events // of each type received on a channel. func countStateEventsByType(ch chan *feed.Event) map[feed.EventType]int { diff --git a/beacon-chain/blockchain/testing/mock.go b/beacon-chain/blockchain/testing/mock.go index 60531ec99d28..ef792b215208 100644 --- a/beacon-chain/blockchain/testing/mock.go +++ b/beacon-chain/blockchain/testing/mock.go @@ -52,6 +52,8 @@ type ChainService struct { MockCanonicalRoots map[primitives.Slot][32]byte InitSyncBlockRoots map[[32]byte]bool MockPayloadEarly map[[32]byte]bool + MockDataAvailable map[[32]byte]bool + MockDataAvailableErr error ParentPayloadReadyVal *bool BlockSlot primitives.Slot OptimisticRoots map[[32]byte]bool @@ -635,6 +637,16 @@ func (s *ChainService) BlockHash(root [32]byte) ([32]byte, error) { return [32]byte{}, errors.New("block hash not found") } +// HasPayloadBlockHash mocks the same method in the chain service. +func (s *ChainService) HasPayloadBlockHash(root, blockHash [32]byte) bool { + if s.ForkChoiceStore == nil { + return false + } + s.ForkChoiceStore.RLock() + defer s.ForkChoiceStore.RUnlock() + return s.ForkChoiceStore.HasPayloadBlockHash(root, blockHash) +} + // IsOptimisticForRoot mocks the same method in the chain service. func (s *ChainService) IsOptimisticForRoot(_ context.Context, root [32]byte) (bool, error) { s.OptimisticCheckRootReceived = root @@ -809,6 +821,14 @@ func (s *ChainService) PayloadEarly(root [32]byte) (bool, bool) { return early, ok } +// DataAvailable mocks the same method in the chain service. +func (s *ChainService) DataAvailable(_ context.Context, root [32]byte, _ primitives.Slot) (bool, error) { + if s.MockDataAvailableErr != nil { + return false, s.MockDataAvailableErr + } + return s.MockDataAvailable[root], nil +} + // FullBeatsEmpty mocks the same method in the chain service. func (s *ChainService) FullBeatsEmpty(root [32]byte) bool { if s.ForkChoiceStore != nil { diff --git a/beacon-chain/forkchoice/doubly-linked-tree/gloas.go b/beacon-chain/forkchoice/doubly-linked-tree/gloas.go index 16d677a7db97..fe3577926e52 100644 --- a/beacon-chain/forkchoice/doubly-linked-tree/gloas.go +++ b/beacon-chain/forkchoice/doubly-linked-tree/gloas.go @@ -541,7 +541,9 @@ func (f *ForkChoice) SetPTCVote(root [32]byte, ptcIdx uint64, payloadPresent, bl if n == nil { return } - ptcVoteCount.Inc() + if !n.node.payloadAttesters.BitAt(ptcIdx) { + ptcVoteCount.Inc() + } n.node.payloadAttesters.SetBitAt(ptcIdx, true) n.node.payloadAvailabilityVote.SetBitAt(ptcIdx, payloadPresent) n.node.payloadDataAvailabilityVote.SetBitAt(ptcIdx, blobDataAvailable) @@ -575,6 +577,19 @@ func (f *ForkChoice) HasFullNode(root [32]byte) bool { return ok } +// HasPayloadBlockHash reports whether blockHash is an available payload parent at root. +func (f *ForkChoice) HasPayloadBlockHash(root, blockHash [32]byte) bool { + en := f.store.emptyNodeByRoot[root] + if en == nil || en.node == nil { + return false + } + if blockHash == en.node.blockHash { + _, ok := f.store.fullNodeByRoot[root] + return ok + } + return blockHash == f.store.parentHash(en) +} + // FullBeatsEmpty returns whether fork choice would select the full payload variant // for the given beacon block root. The caller MUST hold the forkchoice lock. func (f *ForkChoice) FullBeatsEmpty(root [32]byte) bool { diff --git a/beacon-chain/forkchoice/doubly-linked-tree/gloas_test.go b/beacon-chain/forkchoice/doubly-linked-tree/gloas_test.go index b5f81a657ee2..ed7cfd547201 100644 --- a/beacon-chain/forkchoice/doubly-linked-tree/gloas_test.go +++ b/beacon-chain/forkchoice/doubly-linked-tree/gloas_test.go @@ -434,6 +434,28 @@ func TestParentHash_UnknownRoot(t *testing.T) { assert.Equal(t, [32]byte{}, f.ParentHash(indexToHash(999))) } +func TestHasPayloadBlockHash(t *testing.T) { + f := setupGloas(t, 0, 0) + ctx := t.Context() + + root := indexToHash(1) + fullHash := indexToHash(100) + emptyHash := params.BeaconConfig().ZeroHash + st, roblock, err := prepareGloasForkchoiceState(ctx, 1, root, emptyHash, fullHash, emptyHash, 0, 0) + require.NoError(t, err) + require.NoError(t, f.InsertNode(ctx, st, roblock)) + + assert.Equal(t, true, f.HasPayloadBlockHash(root, emptyHash)) + assert.Equal(t, false, f.HasPayloadBlockHash(root, fullHash)) + assert.Equal(t, false, f.HasPayloadBlockHash(root, indexToHash(999))) + + pe, err := prepareGloasForkchoicePayload(root) + require.NoError(t, err) + require.NoError(t, f.InsertPayload(pe)) + assert.Equal(t, true, f.HasPayloadBlockHash(root, fullHash)) + assert.Equal(t, false, f.HasPayloadBlockHash(indexToHash(999), fullHash)) +} + func TestGloasBlock_ChildBuildsOnFull(t *testing.T) { f := setupGloas(t, 0, 0) ctx := t.Context() diff --git a/beacon-chain/forkchoice/interfaces.go b/beacon-chain/forkchoice/interfaces.go index 9d3a41b485b1..ba95be60a09f 100644 --- a/beacon-chain/forkchoice/interfaces.go +++ b/beacon-chain/forkchoice/interfaces.go @@ -96,6 +96,7 @@ type FastGetter interface { Weight(root [32]byte) (uint64, error) ConsensusNodeWeight(root [32]byte) (uint64, error) PayloadWeights(root [32]byte) (emptyWeight, fullWeight uint64, err error) + HasPayloadBlockHash(root, blockHash [32]byte) bool PTCVotedEarlyAndAvailable(root [32]byte) bool PTCVotedLate(root [32]byte) bool ParentRoot(root [32]byte) ([32]byte, error) diff --git a/beacon-chain/forkchoice/ro.go b/beacon-chain/forkchoice/ro.go index 88db2a4a609d..1907a40ab25a 100644 --- a/beacon-chain/forkchoice/ro.go +++ b/beacon-chain/forkchoice/ro.go @@ -177,6 +177,13 @@ func (ro *ROForkChoice) PayloadWeights(root [32]byte) (uint64, uint64, error) { return ro.getter.PayloadWeights(root) } +// HasPayloadBlockHash delegates to the underlying forkchoice call, under a lock. +func (ro *ROForkChoice) HasPayloadBlockHash(root, blockHash [32]byte) bool { + ro.l.RLock() + defer ro.l.RUnlock() + return ro.getter.HasPayloadBlockHash(root, blockHash) +} + // IsOptimistic delegates to the underlying forkchoice call, under a lock. func (ro *ROForkChoice) IsOptimistic(root [32]byte) (bool, error) { ro.l.RLock() diff --git a/beacon-chain/forkchoice/ro_test.go b/beacon-chain/forkchoice/ro_test.go index 8c346ea5216e..c26c5a7271e3 100644 --- a/beacon-chain/forkchoice/ro_test.go +++ b/beacon-chain/forkchoice/ro_test.go @@ -50,6 +50,7 @@ const ( dependentRootForEpochCalled canonicalNodeAtSlotCalled payloadWeightsCalled + hasPayloadBlockHashCalled ) func _discard(t *testing.T, e error) { @@ -197,6 +198,11 @@ func TestROLocking(t *testing.T) { call: gasLimitCalled, cb: func(g FastGetter) { _, err := g.GasLimit([32]byte{}); _discard(t, err) }, }, + { + name: "hasPayloadBlockHashCalled", + call: hasPayloadBlockHashCalled, + cb: func(g FastGetter) { g.HasPayloadBlockHash([32]byte{}, [32]byte{}) }, + }, { name: "parentHashCalled", call: parentHashCalled, @@ -344,6 +350,11 @@ func (ro *mockROForkchoice) PayloadWeights(_ [32]byte) (uint64, uint64, error) { return 0, 0, nil } +func (ro *mockROForkchoice) HasPayloadBlockHash(_, _ [32]byte) bool { + ro.calls = append(ro.calls, hasPayloadBlockHashCalled) + return false +} + func (ro *mockROForkchoice) IsOptimistic(_ [32]byte) (bool, error) { ro.calls = append(ro.calls, isOptimisticCalled) return false, nil diff --git a/beacon-chain/node/config.go b/beacon-chain/node/config.go index e50668751374..e37f4c21f80f 100644 --- a/beacon-chain/node/config.go +++ b/beacon-chain/node/config.go @@ -92,6 +92,17 @@ func configureBuilderCircuitBreaker(cliCtx *cli.Context) error { return nil } +func configureBuilderGetHeaderTimeout(cliCtx *cli.Context) error { + if cliCtx.IsSet(flags.BuilderGetHeaderTimeout.Name) { + c := params.BeaconConfig().Copy() + c.BuilderGetHeaderTimeout = cliCtx.Duration(flags.BuilderGetHeaderTimeout.Name) + if err := params.SetActive(c); err != nil { + return err + } + } + return nil +} + func configureSlotsPerArchivedPoint(cliCtx *cli.Context) error { if cliCtx.IsSet(flags.SlotsPerArchivedPoint.Name) { c := params.BeaconConfig().Copy() diff --git a/beacon-chain/node/config_test.go b/beacon-chain/node/config_test.go index 0dc9d39c86a0..56d57994af0e 100644 --- a/beacon-chain/node/config_test.go +++ b/beacon-chain/node/config_test.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/OffchainLabs/prysm/v7/cmd" "github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags" @@ -39,6 +40,20 @@ func TestConfigureHistoricalSlasher(t *testing.T) { ) } +func TestConfigureBuilderGetHeaderTimeout(t *testing.T) { + params.SetupTestConfigCleanup(t) + + app := cli.App{} + set := flag.NewFlagSet("test", 0) + set.Duration(flags.BuilderGetHeaderTimeout.Name, 0, "") + require.NoError(t, set.Set(flags.BuilderGetHeaderTimeout.Name, "950ms")) + cliCtx := cli.NewContext(&app, set, nil) + + require.NoError(t, configureBuilderGetHeaderTimeout(cliCtx)) + + assert.Equal(t, 950*time.Millisecond, params.BeaconConfig().BuilderGetHeaderTimeout) +} + func TestConfigureSlotsPerArchivedPoint(t *testing.T) { params.SetupTestConfigCleanup(t) diff --git a/beacon-chain/node/node.go b/beacon-chain/node/node.go index 5feaf616b211..ca1637cfdfdc 100644 --- a/beacon-chain/node/node.go +++ b/beacon-chain/node/node.go @@ -317,6 +317,10 @@ func configureBeacon(cliCtx *cli.Context) error { return errors.Wrap(err, "could not configure builder circuit breaker") } + if err := configureBuilderGetHeaderTimeout(cliCtx); err != nil { + return errors.Wrap(err, "could not configure builder getHeader timeout") + } + if err := configureSlotsPerArchivedPoint(cliCtx); err != nil { return errors.Wrap(err, "could not configure slots per archived point") } diff --git a/beacon-chain/rpc/core/validator.go b/beacon-chain/rpc/core/validator.go index 524bc41ca76f..57a5d86ca580 100644 --- a/beacon-chain/rpc/core/validator.go +++ b/beacon-chain/rpc/core/validator.go @@ -939,7 +939,7 @@ func (s *Service) PayloadAttestationData( ctx context.Context, slot primitives.Slot, ) (*ethpb.PayloadAttestationData, *RpcError) { - _, span := trace.StartSpan(ctx, "coreService.PayloadAttestationData") + ctx, span := trace.StartSpan(ctx, "coreService.PayloadAttestationData") defer span.End() if slots.ToEpoch(slot) < params.BeaconConfig().GloasForkEpoch { @@ -965,7 +965,7 @@ func (s *Service) PayloadAttestationData( if cached := s.payloadAttestationData.Load(); cached != nil && cached.Slot == slot { return cached, nil } - data, rpcErr := s.buildPayloadAttestationData(slot) + data, rpcErr := s.buildPayloadAttestationData(ctx, slot) if rpcErr != nil { return rpcErr, nil } @@ -1012,7 +1012,7 @@ func (s *Service) hasCanonicalShuffling(root [32]byte, slot primitives.Slot) boo // buildPayloadAttestationData builds a payload attestation message for the validator to sign. It attempts first // to build from the highest received slot but only if it is compatible with the head view. -func (s *Service) buildPayloadAttestationData(slot primitives.Slot) (*ethpb.PayloadAttestationData, *RpcError) { +func (s *Service) buildPayloadAttestationData(ctx context.Context, slot primitives.Slot) (*ethpb.PayloadAttestationData, *RpcError) { highestReceivedSlot := s.ForkchoiceFetcher.HighestReceivedBlockSlot() if highestReceivedSlot != slot { return nil, &RpcError{Reason: NoContent, Err: fmt.Errorf("no block found at slot=%d", slot)} @@ -1024,11 +1024,15 @@ func (s *Service) buildPayloadAttestationData(slot primitives.Slot) (*ethpb.Payl if !s.hasCanonicalShuffling(root, slot) { return nil, &RpcError{Reason: Unavailable, Err: fmt.Errorf("no canonical shuffling block for slot %d", slot)} } - payloadEarly, _ := s.ForkchoiceFetcher.PayloadEarly(root) + available, err := s.ChainInfoFetcher.DataAvailable(ctx, root, slot) + if err != nil { + return nil, &RpcError{Reason: Internal, Err: fmt.Errorf("could not check data availability for block root %#x: %w", root, err)} + } + payloadEarly, _ := s.ChainInfoFetcher.PayloadEarly(root) return ðpb.PayloadAttestationData{ BeaconBlockRoot: root[:], Slot: slot, PayloadPresent: payloadEarly, - BlobDataAvailable: s.ForkchoiceFetcher.HasFullNode(root), + BlobDataAvailable: available, }, nil } diff --git a/beacon-chain/rpc/core/validator_test.go b/beacon-chain/rpc/core/validator_test.go index 1ba6f226a754..96281173c427 100644 --- a/beacon-chain/rpc/core/validator_test.go +++ b/beacon-chain/rpc/core/validator_test.go @@ -2,6 +2,7 @@ package core import ( "encoding/binary" + "errors" "sync" "testing" "time" @@ -217,7 +218,7 @@ func TestPayloadAttestationData(t *testing.T) { slot := primitives.Slot(5) root := bytesutil.PadTo([]byte("head-root"), 32) chain := &mockChain.ChainService{Slot: &slot, Root: root} - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} data, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.IsNil(t, rpcErr) @@ -238,10 +239,10 @@ func TestPayloadAttestationData(t *testing.T) { Slot: &slot, Root: root, MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, - MockCanonicalFull: map[primitives.Slot]bool{slot: true}, + MockDataAvailable: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, MockPayloadEarly: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} data, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.IsNil(t, rpcErr) @@ -250,6 +251,50 @@ func TestPayloadAttestationData(t *testing.T) { assert.Equal(t, true, data.PayloadPresent) assert.Equal(t, true, data.BlobDataAvailable) }) + t.Run("data available while payload not yet inserted", func(t *testing.T) { + params.SetupTestConfigCleanup(t) + cfg := params.BeaconConfig().Copy() + cfg.GloasForkEpoch = 0 + params.OverrideBeaconConfig(cfg) + + slot := primitives.Slot(5) + root := bytesutil.PadTo([]byte("head-root"), 32) + chain := &mockChain.ChainService{ + Slot: &slot, + Root: root, + MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, + MockCanonicalFull: map[primitives.Slot]bool{slot: false}, + MockDataAvailable: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, + MockPayloadEarly: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, + } + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} + + data, rpcErr := s.PayloadAttestationData(t.Context(), slot) + require.IsNil(t, rpcErr) + assert.Equal(t, true, data.PayloadPresent) + assert.Equal(t, true, data.BlobDataAvailable) + }) + t.Run("data availability lookup error → Internal", func(t *testing.T) { + params.SetupTestConfigCleanup(t) + cfg := params.BeaconConfig().Copy() + cfg.GloasForkEpoch = 0 + params.OverrideBeaconConfig(cfg) + + slot := primitives.Slot(5) + root := bytesutil.PadTo([]byte("head-root"), 32) + chain := &mockChain.ChainService{ + Slot: &slot, + Root: root, + MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, + MockDataAvailableErr: errors.New("block lookup failed"), + } + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} + + _, rpcErr := s.PayloadAttestationData(t.Context(), slot) + require.NotNil(t, rpcErr) + assert.Equal(t, ErrorReason(Internal), rpcErr.Reason) + assert.ErrorContains(t, "could not check data availability", rpcErr.Err) + }) t.Run("before PTC deadline and not final → Unavailable", func(t *testing.T) { params.SetupTestConfigCleanup(t) cfg := params.BeaconConfig().Copy() @@ -262,7 +307,7 @@ func TestPayloadAttestationData(t *testing.T) { Genesis: time.Now(), Root: bytesutil.PadTo([]byte{0xAA}, 32), } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} _, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.NotNil(t, rpcErr) @@ -283,10 +328,10 @@ func TestPayloadAttestationData(t *testing.T) { Genesis: time.Now(), Root: root, MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, - MockCanonicalFull: map[primitives.Slot]bool{slot: true}, + MockDataAvailable: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, MockPayloadEarly: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} data, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.IsNil(t, rpcErr) @@ -309,7 +354,7 @@ func TestPayloadAttestationData(t *testing.T) { MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, MockCanonicalFull: map[primitives.Slot]bool{slot: false}, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} first, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.IsNil(t, rpcErr) @@ -355,7 +400,7 @@ func TestPayloadAttestationData(t *testing.T) { MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, MockCanonicalFull: map[primitives.Slot]bool{slot: false}, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} const callers = 16 results := make([]*ethpb.PayloadAttestationData, callers) @@ -395,7 +440,7 @@ func TestPayloadAttestationData(t *testing.T) { return bytesutil.ToBytes32(bytesutil.PadTo([]byte{0x02}, 32)), nil }, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} _, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.NotNil(t, rpcErr) @@ -416,14 +461,14 @@ func TestPayloadAttestationData(t *testing.T) { Slot: &slot, Root: root, MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, - MockCanonicalFull: map[primitives.Slot]bool{slot: true}, + MockDataAvailable: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, MockPayloadEarly: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, HeadDependentRoot: dependent, DependentRootCB: func(_ [32]byte, _ primitives.Epoch) ([32]byte, error) { return dependent, nil }, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} data, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.IsNil(t, rpcErr) @@ -453,7 +498,7 @@ func TestPayloadAttestationData(t *testing.T) { return dependent, nil }, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} data, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.IsNil(t, rpcErr) diff --git a/beacon-chain/rpc/eth/validator/handlers_test.go b/beacon-chain/rpc/eth/validator/handlers_test.go index 9df1eda01712..a0ba0726ad4e 100644 --- a/beacon-chain/rpc/eth/validator/handlers_test.go +++ b/beacon-chain/rpc/eth/validator/handlers_test.go @@ -4052,7 +4052,7 @@ func TestGetPayloadAttestationData(t *testing.T) { HeadFetcher: chainService, TimeFetcher: chainService, OptimisticModeFetcher: chainService, - CoreService: &core.Service{GenesisTimeFetcher: chainService, ForkchoiceFetcher: chainService, HeadFetcher: chainService}, + CoreService: &core.Service{GenesisTimeFetcher: chainService, ForkchoiceFetcher: chainService, HeadFetcher: chainService, ChainInfoFetcher: chainService}, } request := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/validator/payload_attestation_data?slot=0", nil) @@ -4100,7 +4100,7 @@ func TestGetPayloadAttestationData(t *testing.T) { Slot: &slot, Root: root, MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, - MockCanonicalFull: map[primitives.Slot]bool{slot: true}, + MockDataAvailable: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, MockPayloadEarly: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, } s := &Server{ @@ -4108,7 +4108,7 @@ func TestGetPayloadAttestationData(t *testing.T) { HeadFetcher: chainService, TimeFetcher: chainService, OptimisticModeFetcher: chainService, - CoreService: &core.Service{GenesisTimeFetcher: chainService, ForkchoiceFetcher: chainService, HeadFetcher: chainService}, + CoreService: &core.Service{GenesisTimeFetcher: chainService, ForkchoiceFetcher: chainService, HeadFetcher: chainService, ChainInfoFetcher: chainService}, } request := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/validator/payload_attestation_data?slot=5", nil) @@ -4141,7 +4141,7 @@ func TestGetPayloadAttestationData(t *testing.T) { HeadFetcher: chainService, TimeFetcher: chainService, OptimisticModeFetcher: chainService, - CoreService: &core.Service{GenesisTimeFetcher: chainService, ForkchoiceFetcher: chainService, HeadFetcher: chainService}, + CoreService: &core.Service{GenesisTimeFetcher: chainService, ForkchoiceFetcher: chainService, HeadFetcher: chainService, ChainInfoFetcher: chainService}, } request := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/validator/payload_attestation_data?slot=5", nil) diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/payload_attestation_test.go b/beacon-chain/rpc/prysm/v1alpha1/validator/payload_attestation_test.go index 2b176742bedf..2c9cd154fee7 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/payload_attestation_test.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/payload_attestation_test.go @@ -55,7 +55,7 @@ func TestPayloadAttestationData_OK(t *testing.T) { TimeFetcher: chain, HeadFetcher: chain, ForkchoiceFetcher: chain, - CoreService: &core.Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain}, + CoreService: &core.Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain}, } resp, err := vs.PayloadAttestationData(t.Context(), ðpb.PayloadAttestationDataRequest{Slot: slot}) diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bellatrix.go b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bellatrix.go index 47c2a76e314a..5713d3bb430f 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bellatrix.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bellatrix.go @@ -208,7 +208,13 @@ func (vs *Server) getPayloadHeaderFromBuilder( return nil, err } - ctx, cancel := context.WithTimeout(ctx, blockBuilderTimeout) + // Bound the getHeader call by the configurable builder timeout, falling back + // to the default BUILDER_PROPOSAL_DELAY_TOLERANCE when unset. + timeout := blockBuilderTimeout + if t := params.BeaconConfig().BuilderGetHeaderTimeout; t > 0 { + timeout = t + } + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() signedBid, err := vs.BlockBuilder.GetHeader(ctx, slot, bytesutil.ToBytes32(h.BlockHash()), pk) diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid.go b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid.go index 1915e1046b13..7aacdea7a057 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid.go @@ -223,7 +223,9 @@ func (vs *Server) validateBuilderBid(head state.BeaconState, signed *ethpb.Signe if err := v.VerifyParentBlockRootSeen(func(root [32]byte) bool { return root == q.parentRoot }); err != nil { return err } - if err := v.VerifyParentBlockHash(func([32]byte) ([32]byte, error) { return q.parentHash, nil }); err != nil { + if err := v.VerifyParentBlockHash(func(root, hash [32]byte) bool { + return root == q.parentRoot && hash == q.parentHash + }); err != nil { return err } if err := v.VerifyBuilderActive(head); err != nil { diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid_builder_test.go b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid_builder_test.go index 19a3a7565791..b4775366ee42 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid_builder_test.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid_builder_test.go @@ -27,7 +27,7 @@ type fakeBidVerifier struct { slotErr, activeErr, versionErr, coverErr, blobErr, randaoErr, sigErr error rootSeenErr, parentHashErr, feeErr, gasErr error rootSeenFn func([32]byte) bool - resolveFn func([32]byte) ([32]byte, error) + hasPayloadFn func([32]byte, [32]byte) bool } func (v *fakeBidVerifier) VerifyCurrentOrNextSlot() error { return nil } @@ -49,8 +49,8 @@ func (v *fakeBidVerifier) VerifyParentBlockRootSeen(fn func([32]byte) bool) erro return v.rootSeenErr } func (v *fakeBidVerifier) VerifyBidSlotHigherThanParent(primitives.Slot) error { return nil } -func (v *fakeBidVerifier) VerifyParentBlockHash(fn func([32]byte) ([32]byte, error)) error { - v.resolveFn = fn +func (v *fakeBidVerifier) VerifyParentBlockHash(fn func([32]byte, [32]byte) bool) error { + v.hasPayloadFn = fn return v.parentHashErr } func (v *fakeBidVerifier) VerifyGasLimitTargetCompatible(uint64, uint64) error { return v.gasErr } @@ -203,9 +203,8 @@ func TestValidateBuilderBid(t *testing.T) { // The parent-linkage closures must match only the block being produced. require.Equal(t, true, captured.rootSeenFn(parentRoot)) require.Equal(t, false, captured.rootSeenFn([32]byte{7, 7, 7})) - gotHash, err := captured.resolveFn([32]byte{}) - require.NoError(t, err) - require.Equal(t, parentHash, gotHash) + require.Equal(t, true, captured.hasPayloadFn(parentRoot, parentHash)) + require.Equal(t, false, captured.hasPayloadFn(parentRoot, [32]byte{8, 8, 8})) }) t.Run("verifier check fails", func(t *testing.T) { diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_submit_bid.go b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_submit_bid.go index 256b0123b3a6..1c8c781c84cb 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_submit_bid.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_submit_bid.go @@ -113,7 +113,7 @@ func (vs *Server) validateSubmittedBid(ctx context.Context, signed *ethpb.Signed if err := v.VerifyBuilderCanCoverBid(st); err != nil { return status.Errorf(codes.InvalidArgument, "%v", err) } - if err := v.VerifyParentBlockHash(vs.ForkchoiceFetcher.BlockHash); err != nil { + if err := v.VerifyParentBlockHash(vs.ForkchoiceFetcher.HasPayloadBlockHash); err != nil { return status.Errorf(codes.InvalidArgument, "%v", err) } parentGasLimit, err := vs.ForkchoiceFetcher.GasLimit(parentRoot) diff --git a/beacon-chain/sync/metrics.go b/beacon-chain/sync/metrics.go index 4a9e31c97a12..abb836b8af11 100644 --- a/beacon-chain/sync/metrics.go +++ b/beacon-chain/sync/metrics.go @@ -283,13 +283,6 @@ var ( }, ) - dataColumnSidecarsObtainedViaELCount = promauto.NewSummary( - prometheus.SummaryOpts{ - Name: "data_column_obtained_via_el_count", - Help: "Count the number of data column sidecars obtained via the execution layer.", - }, - ) - ignoredPreJustifiedBlockCount = promauto.NewCounter(prometheus.CounterOpts{ Name: "gossip_ignored_pre_justified_block_total", Help: "Count of blocks ignored because their canonical parent is before the justified checkpoint.", diff --git a/beacon-chain/sync/validate_execution_payload_bid.go b/beacon-chain/sync/validate_execution_payload_bid.go index 6de9ce2f04a4..f438bb8e07e0 100644 --- a/beacon-chain/sync/validate_execution_payload_bid.go +++ b/beacon-chain/sync/validate_execution_payload_bid.go @@ -123,7 +123,7 @@ func (s *Service) validateExecutionPayloadBidGossip(ctx context.Context, pid pee } // [IGNORE] bid.parent_block_hash is the block hash of a known execution payload in fork choice // and bid.gas_limit is compatible with parent_gas_limit and the proposer's target. - if err := v.VerifyParentBlockHash(s.cfg.chain.BlockHash); err != nil { + if err := v.VerifyParentBlockHash(s.cfg.chain.HasPayloadBlockHash); err != nil { return pubsub.ValidationIgnore, err } parentGasLimit, err := s.cfg.chain.GasLimit(parentBlockRoot) diff --git a/beacon-chain/sync/validate_execution_payload_bid_test.go b/beacon-chain/sync/validate_execution_payload_bid_test.go index db1df0ba9c61..3a11edf32b43 100644 --- a/beacon-chain/sync/validate_execution_payload_bid_test.go +++ b/beacon-chain/sync/validate_execution_payload_bid_test.go @@ -463,7 +463,7 @@ func (m *mockExecutionPayloadBidVerifier) VerifyBidSlotHigherThanParent(primitiv return m.errSlotHigherThanParent } -func (m *mockExecutionPayloadBidVerifier) VerifyParentBlockHash(func([32]byte) ([32]byte, error)) error { +func (m *mockExecutionPayloadBidVerifier) VerifyParentBlockHash(func([32]byte, [32]byte) bool) error { return m.errParentBlockHash } diff --git a/beacon-chain/verification/execution_payload_bid.go b/beacon-chain/verification/execution_payload_bid.go index 96d14816cacb..149409d73fd5 100644 --- a/beacon-chain/verification/execution_payload_bid.go +++ b/beacon-chain/verification/execution_payload_bid.go @@ -285,23 +285,19 @@ func (v *BidVerifier) VerifyBidSlotHigherThanParent(parentSlot primitives.Slot) return nil } -// VerifyParentBlockHash verifies the parent execution block hash matches forkchoice for the bid parent root. -func (v *BidVerifier) VerifyParentBlockHash(resolveBlockHash func([32]byte) ([32]byte, error)) (err error) { +// VerifyParentBlockHash verifies that the bid references an available parent payload. +func (v *BidVerifier) VerifyParentBlockHash(hasPayloadBlockHash func([32]byte, [32]byte) bool) (err error) { defer v.record(RequireBidParentBlockHashValid, &err) bid, err := v.b.Bid() if err != nil { return errors.Wrap(err, "failed to get bid") } - if resolveBlockHash == nil { - return fmt.Errorf("%w: no parent block hash resolver", ErrBidParentBlockHashMismatch) + if hasPayloadBlockHash == nil { + return fmt.Errorf("%w: no parent block hash lookup", ErrBidParentBlockHashMismatch) } - parentHash, err := resolveBlockHash(bid.ParentBlockRoot()) - if err != nil { - return errors.Wrap(err, "failed to resolve parent block hash") - } - if parentHash != bid.ParentBlockHash() { - return fmt.Errorf("%w: bid=%#x forkchoice=%#x", ErrBidParentBlockHashMismatch, bid.ParentBlockHash(), parentHash) + if !hasPayloadBlockHash(bid.ParentBlockRoot(), bid.ParentBlockHash()) { + return fmt.Errorf("%w: root=%#x hash=%#x", ErrBidParentBlockHashMismatch, bid.ParentBlockRoot(), bid.ParentBlockHash()) } return nil } diff --git a/beacon-chain/verification/execution_payload_bid_test.go b/beacon-chain/verification/execution_payload_bid_test.go index 7c6bd1c48b0d..1b048e32bf6f 100644 --- a/beacon-chain/verification/execution_payload_bid_test.go +++ b/beacon-chain/verification/execution_payload_bid_test.go @@ -174,14 +174,15 @@ func TestBidVerifier_VerifyParentBlockHash(t *testing.T) { require.NoError(t, err) wantHash := [32]byte(signed.Message.ParentBlockHash) + wantRoot := [32]byte(signed.Message.ParentBlockRoot) verifier := &BidVerifier{results: newResults(RequireBidParentBlockHashValid), b: wrapped} - require.NoError(t, verifier.VerifyParentBlockHash(func([32]byte) ([32]byte, error) { - return wantHash, nil + require.NoError(t, verifier.VerifyParentBlockHash(func(root, hash [32]byte) bool { + return root == wantRoot && hash == wantHash })) verifier = &BidVerifier{results: newResults(RequireBidParentBlockHashValid), b: wrapped} - require.ErrorIs(t, verifier.VerifyParentBlockHash(func([32]byte) ([32]byte, error) { - return [32]byte{0xFF}, nil + require.ErrorIs(t, verifier.VerifyParentBlockHash(func([32]byte, [32]byte) bool { + return false }), ErrBidParentBlockHashMismatch) } diff --git a/beacon-chain/verification/interface.go b/beacon-chain/verification/interface.go index c4a4c0daa677..24089bcceeae 100644 --- a/beacon-chain/verification/interface.go +++ b/beacon-chain/verification/interface.go @@ -110,7 +110,7 @@ type ExecutionPayloadBidVerifier interface { VerifyPrevRandao(state.ReadOnlyBeaconState) error VerifyParentBlockRootSeen(func([32]byte) bool) error VerifyBidSlotHigherThanParent(parentSlot primitives.Slot) error - VerifyParentBlockHash(func([32]byte) ([32]byte, error)) error + VerifyParentBlockHash(func([32]byte, [32]byte) bool) error VerifyGasLimitTargetCompatible(parentGasLimit, targetGasLimit uint64) error VerifyBuilderCanCoverBid(state.ReadOnlyBeaconState) error VerifySignature(state.ReadOnlyBeaconState) error diff --git a/changelog/bastin_progressive-ssz-functions.md b/changelog/bastin_progressive-ssz-functions.md new file mode 100644 index 000000000000..bce7e4da0dd5 --- /dev/null +++ b/changelog/bastin_progressive-ssz-functions.md @@ -0,0 +1,3 @@ +### Added + +- progressive merklization functions in the ssz package diff --git a/changelog/ethermachine_proposer-timing-games.md b/changelog/ethermachine_proposer-timing-games.md new file mode 100644 index 000000000000..a94e278685e7 --- /dev/null +++ b/changelog/ethermachine_proposer-timing-games.md @@ -0,0 +1,3 @@ +### Added + +- Opt-in proposer timing games support: `--enable-proposer-timing-games` and `--proposer-timing-game-delay` validator flags delay the block proposal request within the slot (clamped to stay safely before the attestation deadline), and a new `--builder-getheader-timeout` beacon node flag makes the previously hardcoded 1s builder getHeader timeout configurable. diff --git a/changelog/pvl-update-e2e-lh.md b/changelog/pvl-update-e2e-lh.md new file mode 100644 index 000000000000..847b927224fc --- /dev/null +++ b/changelog/pvl-update-e2e-lh.md @@ -0,0 +1,3 @@ +### Ignored + +- Updated lighthouse version for e2e testing diff --git a/changelog/terence_fix-gloas-bid-parent-payload-status.md b/changelog/terence_fix-gloas-bid-parent-payload-status.md new file mode 100644 index 000000000000..8babdaae00c9 --- /dev/null +++ b/changelog/terence_fix-gloas-bid-parent-payload-status.md @@ -0,0 +1,3 @@ +### Fixed + +- Accept execution payload bids building on the parent's empty branch, fixing valid bid rejections after a missed payload reveal. diff --git a/changelog/terence_fix_ptc_blob_da_getter.md b/changelog/terence_fix_ptc_blob_da_getter.md new file mode 100644 index 000000000000..c4f023d6ea27 --- /dev/null +++ b/changelog/terence_fix_ptc_blob_da_getter.md @@ -0,0 +1,3 @@ +### Fixed + +- PTC payload attestation now reports blob data availability from the data column store instead of forkchoice payload insertion. diff --git a/changelog/terence_fix_ptc_vote_count.md b/changelog/terence_fix_ptc_vote_count.md new file mode 100644 index 000000000000..6c775ad04ac7 --- /dev/null +++ b/changelog/terence_fix_ptc_vote_count.md @@ -0,0 +1,3 @@ +### Fixed + +- Count each PTC vote once in `forkchoice_ptc_vote_count` instead of on every re-application. diff --git a/changelog/terence_gloas_envelope_newpayload_metrics.md b/changelog/terence_gloas_envelope_newpayload_metrics.md new file mode 100644 index 000000000000..9dd43cff9628 --- /dev/null +++ b/changelog/terence_gloas_envelope_newpayload_metrics.md @@ -0,0 +1,3 @@ +### Fixed + +- Increment `new_payload_*_node_count` metrics on the Gloas payload envelope path. diff --git a/changelog/terence_remove_dead_column_el_metric.md b/changelog/terence_remove_dead_column_el_metric.md new file mode 100644 index 000000000000..8407ea9c3bc7 --- /dev/null +++ b/changelog/terence_remove_dead_column_el_metric.md @@ -0,0 +1,3 @@ +### Removed + +- Unused `data_column_obtained_via_el_count` metric that was never recorded. diff --git a/cmd/beacon-chain/flags/base.go b/cmd/beacon-chain/flags/base.go index e26e30f262a4..546682a97b7c 100644 --- a/cmd/beacon-chain/flags/base.go +++ b/cmd/beacon-chain/flags/base.go @@ -78,6 +78,13 @@ var ( " and the beacon will revert to local building.", Value: 0, } + // BuilderGetHeaderTimeout bounds how long the beacon node waits for a builder relay getHeader response. + BuilderGetHeaderTimeout = &cli.DurationFlag{ + Name: "builder-getheader-timeout", + Usage: "Maximum time to wait for a builder relay getHeader response before falling back to local block building (e.g. 1s, 950ms). " + + "Lower values reduce missed-slot risk when using proposer timing games; higher values tolerate slower relays.", + DefaultText: params.BeaconConfig().BuilderGetHeaderTimeout.String(), + } // ExecutionEngineEndpoint provides an HTTP access endpoint to connect to an execution client on the execution layer ExecutionEngineEndpoint = &cli.StringFlag{ Name: "execution-endpoint", diff --git a/cmd/beacon-chain/main.go b/cmd/beacon-chain/main.go index a11258451d36..105353c0bb7e 100644 --- a/cmd/beacon-chain/main.go +++ b/cmd/beacon-chain/main.go @@ -87,6 +87,7 @@ var appFlags = []cli.Flag{ flags.LocalBlockValueBoost, flags.MinBuilderBid, flags.MinBuilderDiff, + flags.BuilderGetHeaderTimeout, flags.BeaconDBPruning, flags.PrunerRetentionEpochs, flags.DisableBuilderSSZ, diff --git a/cmd/beacon-chain/usage.go b/cmd/beacon-chain/usage.go index 66b60f05fe79..ecb31bdc56ff 100644 --- a/cmd/beacon-chain/usage.go +++ b/cmd/beacon-chain/usage.go @@ -144,6 +144,7 @@ var appHelpFlagGroups = []flagGroup{ flags.MevRelayEndpoint, flags.MinBuilderBid, flags.MinBuilderDiff, + flags.BuilderGetHeaderTimeout, flags.SuggestedFeeRecipient, flags.DisableBuilderSSZ, }, diff --git a/config/features/config.go b/config/features/config.go index db4ec9ee06d3..bdd780514e53 100644 --- a/config/features/config.go +++ b/config/features/config.go @@ -92,6 +92,14 @@ type Flags struct { // AggregateIntervals specifies the time durations at which we aggregate attestations preparing for forkchoice. AggregateIntervals [3]time.Duration + // EnableProposerTimingGames delays the block proposal request within the slot so + // builders have more time to accrue MEV (proposer timing games). Off by default. + EnableProposerTimingGames bool + // ProposerTimingGameDelay is the target time into the slot at which the block + // proposal request is released when EnableProposerTimingGames is set. Only read + // when EnableProposerTimingGames is true; the effective value is clamped at use. + ProposerTimingGameDelay time.Duration + // Feature related flags (alignment forced in the end) ForceHead string // ForceHead forces the head block to be a specific block root, the last head block, or the last finalized block. BlacklistedRoots map[[32]byte]struct{} // BlacklistedRoots is a list of roots that are blacklisted from processing. @@ -371,6 +379,11 @@ func ConfigureValidator(ctx *cli.Context) error { } cfg.KeystoreImportDebounceInterval = ctx.Duration(dynamicKeyReloadDebounceInterval.Name) + if ctx.Bool(enableProposerTimingGames.Name) { + logEnabled(enableProposerTimingGames) + cfg.EnableProposerTimingGames = true + cfg.ProposerTimingGameDelay = ctx.Duration(proposerTimingGameDelay.Name) + } Init(cfg) return nil } diff --git a/config/features/config_test.go b/config/features/config_test.go index 0f07670fb46c..ef450100d3f7 100644 --- a/config/features/config_test.go +++ b/config/features/config_test.go @@ -3,6 +3,7 @@ package features import ( "flag" "testing" + "time" validatorflags "github.com/OffchainLabs/prysm/v7/cmd/validator/flags" "github.com/OffchainLabs/prysm/v7/testing/assert" @@ -60,6 +61,31 @@ func TestConfigureValidator_RESTApiEnabledByEndpoint(t *testing.T) { assert.Equal(t, false, Get().EnableBeaconRESTApi) } +func TestConfigureValidator_ProposerTimingGames(t *testing.T) { + defer Init(&Flags{}) + app := cli.App{} + + // Enabling timing games reads the configured delay. + set := flag.NewFlagSet("test", 0) + set.Bool(enableProposerTimingGames.Name, true, "test") + set.Duration(proposerTimingGameDelay.Name, 2*time.Second, "test") + require.NoError(t, set.Set(enableProposerTimingGames.Name, "true")) + require.NoError(t, set.Set(proposerTimingGameDelay.Name, "2s")) + context := cli.NewContext(&app, set, nil) + require.NoError(t, ConfigureValidator(context)) + assert.Equal(t, true, Get().EnableProposerTimingGames) + assert.Equal(t, 2*time.Second, Get().ProposerTimingGameDelay) + + // Without the gate the delay is ignored and stays zero (behavior unchanged). + set = flag.NewFlagSet("test", 0) + set.Duration(proposerTimingGameDelay.Name, 2*time.Second, "test") + require.NoError(t, set.Set(proposerTimingGameDelay.Name, "2s")) + context = cli.NewContext(&app, set, nil) + require.NoError(t, ConfigureValidator(context)) + assert.Equal(t, false, Get().EnableProposerTimingGames) + assert.Equal(t, time.Duration(0), Get().ProposerTimingGameDelay) +} + func TestConfigureBeaconConfig(t *testing.T) { app := cli.App{} set := flag.NewFlagSet("test", 0) diff --git a/config/features/flags.go b/config/features/flags.go index e486ddf9644c..15793120a741 100644 --- a/config/features/flags.go +++ b/config/features/flags.go @@ -227,6 +227,24 @@ var ( Name: "track-equivocations", Usage: "Records proposer equivocations observed on gossip and marks the slot in forkchoice if the equivocation arrives before the configured early deadline.", } + // enableProposerTimingGames opts the validator into proposer timing games: + // delaying the block proposal request within the slot so builders have more + // time to accrue MEV. Gated behind this experimental flag; off by default. + enableProposerTimingGames = &cli.BoolFlag{ + Name: "enable-proposer-timing-games", + Usage: "(Experimental): Delays the block proposal request within the slot so builders have more time to accrue MEV (proposer timing games). " + + "Opt-in and off by default. Tune the delay with --proposer-timing-game-delay. WARNING: this increases the risk of orphaned/reorged blocks; " + + "the effective delay is clamped to remain before the attestation deadline.", + Value: false, + } + // proposerTimingGameDelay is the target time into the slot at which the block + // proposal request is released when timing games are enabled. + proposerTimingGameDelay = &cli.DurationFlag{ + Name: "proposer-timing-game-delay", + Usage: "(Experimental): Target time into the slot at which the block proposal request is released when --enable-proposer-timing-games is set " + + "(e.g. 1500ms, 2s). Clamped to a safe maximum before the attestation deadline. Has no effect unless timing games are enabled.", + Value: 1500 * time.Millisecond, + } ) // devModeFlags holds list of flags that are set when development mode is on. @@ -249,6 +267,8 @@ var ValidatorFlags = append(deprecatedFlags, []cli.Flag{ EnableBeaconRESTApi, DisableDutiesV2, EnableWebFlag, + enableProposerTimingGames, + proposerTimingGameDelay, }...) // E2EValidatorFlags contains a list of the validator feature flags to be tested in E2E. diff --git a/config/params/config.go b/config/params/config.go index d7bde83ff55b..cdce1a0d7eb8 100644 --- a/config/params/config.go +++ b/config/params/config.go @@ -267,6 +267,7 @@ type BeaconChainConfig struct { LocalBlockValueBoost uint64 // LocalBlockValueBoost is the value boost for local block construction. This is used to prioritize local block construction over relay/builder block construction. MinBuilderBid uint64 // MinBuilderBid is the minimum value that the builder's block can have to be considered by this node. MinBuilderDiff uint64 // MinBuilderDiff is the minimum value above the local block value that the builder has to bid to be considered by this node + BuilderGetHeaderTimeout time.Duration // BuilderGetHeaderTimeout is the maximum time to wait for a builder getHeader response (known as BUILDER_PROPOSAL_DELAY_TOLERANCE in the builder spec). Configurable to support proposer timing games. // Execution engine timeout value ExecutionEngineTimeoutValue uint64 // ExecutionEngineTimeoutValue defines the seconds to wait before timing out engine endpoints with execution payload execution semantics (newPayload, forkchoiceUpdated). diff --git a/config/params/mainnet_config.go b/config/params/mainnet_config.go index 895504f9d413..d7138dee4f69 100644 --- a/config/params/mainnet_config.go +++ b/config/params/mainnet_config.go @@ -303,6 +303,8 @@ var mainnetBeaconConfig = &BeaconChainConfig{ // Mevboost circuit breaker MaxBuilderConsecutiveMissedSlots: 3, MaxBuilderEpochMissedSlots: 5, + // Builder getHeader timeout (BUILDER_PROPOSAL_DELAY_TOLERANCE) + BuilderGetHeaderTimeout: 1 * time.Second, // Execution engine timeout value ExecutionEngineTimeoutValue: 8, // 8 seconds default based on: https://github.com/ethereum/execution-apis/blob/main/src/engine/specification.md#core diff --git a/encoding/ssz/BUILD.bazel b/encoding/ssz/BUILD.bazel index e932bf7b07bd..fdc76df6fbae 100644 --- a/encoding/ssz/BUILD.bazel +++ b/encoding/ssz/BUILD.bazel @@ -7,6 +7,7 @@ go_library( "helpers.go", "htrutils.go", "merkleize.go", + "progressive_merkleize.go", "slice_root.go", ], importpath = "github.com/OffchainLabs/prysm/v7/encoding/ssz", @@ -14,6 +15,7 @@ go_library( deps = [ "//config/fieldparams:go_default_library", "//container/trie:go_default_library", + "//crypto/hash:go_default_library", "//crypto/hash/htr:go_default_library", "//encoding/bytesutil:go_default_library", "//proto/engine/v1:go_default_library", @@ -34,6 +36,7 @@ go_test( "htrutils_fuzz_test.go", "htrutils_test.go", "merkleize_test.go", + "progressive_merkleize_test.go", ], embed = [":go_default_library"], deps = [ diff --git a/encoding/ssz/progressive_merkleize.go b/encoding/ssz/progressive_merkleize.go new file mode 100644 index 000000000000..a8a6fa965a3d --- /dev/null +++ b/encoding/ssz/progressive_merkleize.go @@ -0,0 +1,129 @@ +package ssz + +import ( + "encoding/binary" + "fmt" + "math" + + "github.com/OffchainLabs/prysm/v7/crypto/hash" +) + +const maxProgressiveActiveFields = 256 + +// MerkleizeProgressiveChunks computes the progressive Merkle root of 32-byte chunks. +// +// This is the EIP-7916 merkleize_progressive(chunks, num_leaves=1) helper. +// Chunks are split into progressively larger subtrees with capacities 1, 4, 16, +// 64, ...; each subtree root is then folded into the spine from deepest to +// shallowest by hashing hash(subtree_root, successor_root). +func MerkleizeProgressiveChunks(chunks [][32]byte) [32]byte { + if len(chunks) == 0 { + return [32]byte{} + } + + n := len(chunks) + start := 0 + subtreeCapacity := 1 + subtreeRoots := make([][32]byte, 0) + + for start < n { + width := min(subtreeCapacity, n-start) + + subtree := chunks[start : start+width : start+width] + subtreeRoots = append(subtreeRoots, MerkleizeVector(subtree, uint64(subtreeCapacity))) + + start += width + if start >= n { + break + } + + if subtreeCapacity > math.MaxInt/4 { + subtreeCapacity = math.MaxInt + } else { + subtreeCapacity *= 4 + } + } + + hashFunc := hash.CustomSHA256Hasher() + + // Fold successor roots from deepest subtree to shallowest subtree. + root := [32]byte{} + for i := len(subtreeRoots) - 1; i >= 0; i-- { + root = hashPair(hashFunc, subtreeRoots[i], root) + } + return root +} + +// MerkleizeVectorSSZProgressive hashes each element and computes the +// merkleize_progressive body root over the resulting field roots. +func MerkleizeVectorSSZProgressive[T Hashable](elements []T) ([32]byte, error) { + roots := make([][32]byte, len(elements)) + for i, el := range elements { + r, err := el.HashTreeRoot() + if err != nil { + return [32]byte{}, err + } + roots[i] = r + } + return MerkleizeProgressiveChunks(roots), nil +} + +// MerkleizeListSSZProgressive hashes each element and computes the progressive +// list root by mixing in the element count. +func MerkleizeListSSZProgressive[T Hashable](elements []T) ([32]byte, error) { + body, err := MerkleizeVectorSSZProgressive(elements) + if err != nil { + return [32]byte{}, err + } + + var length [32]byte + binary.LittleEndian.PutUint64(length[:8], uint64(len(elements))) + return MixInLength(body, length[:]), nil +} + +// SliceRootProgressive computes the progressive list root of hashable elements. +func SliceRootProgressive[T Hashable](slice []T) ([32]byte, error) { + return MerkleizeListSSZProgressive(slice) +} + +// ByteSliceRootProgressive computes the progressive list root of a byte slice +// interpreted as ProgressiveByteList (alias of ProgressiveList[byte]). +func ByteSliceRootProgressive(slice []byte) ([32]byte, error) { + var chunks [][32]byte + if len(slice) > 0 { + var err error + chunks, err = PackByChunk([][]byte{slice}) + if err != nil { + return [32]byte{}, err + } + } + + bytesRoot := MerkleizeProgressiveChunks(chunks) + var length [32]byte + binary.LittleEndian.PutUint64(length[:8], uint64(len(slice))) + return MixInLength(bytesRoot, length[:]), nil +} + +// MixInActiveFields computes hash(root, pack_bits(activeFields)) where +// activeFields is restricted to at most 256 bits. +func MixInActiveFields(root [32]byte, activeFields []bool) ([32]byte, error) { + if len(activeFields) > maxProgressiveActiveFields { + return [32]byte{}, fmt.Errorf("active fields length %d exceeds maximum %d", len(activeFields), maxProgressiveActiveFields) + } + + var packed [32]byte + for i, active := range activeFields { + if !active { + continue + } + packed[i/8] |= 1 << (uint(i) % 8) + } + return hashPair(hash.Hash, root, packed), nil +} + +func hashPair(hashFunc func([]byte) [32]byte, left, right [32]byte) [32]byte { + var input [64]byte + copy(input[:32], left[:]) + copy(input[32:], right[:]) + return hashFunc(input[:]) +} diff --git a/encoding/ssz/progressive_merkleize_test.go b/encoding/ssz/progressive_merkleize_test.go new file mode 100644 index 000000000000..c0a9b3143eff --- /dev/null +++ b/encoding/ssz/progressive_merkleize_test.go @@ -0,0 +1,187 @@ +package ssz_test + +import ( + "encoding/binary" + "errors" + "fmt" + "slices" + "testing" + + "github.com/OffchainLabs/prysm/v7/crypto/hash" + "github.com/OffchainLabs/prysm/v7/encoding/ssz" + "github.com/OffchainLabs/prysm/v7/testing/require" +) + +type staticHashable struct { + root [32]byte + err error +} + +func (s staticHashable) HashTreeRoot() ([32]byte, error) { + if s.err != nil { + return [32]byte{}, s.err + } + return s.root, nil +} + +func chunkFromIndex(i int) [32]byte { + var out [32]byte + binary.LittleEndian.PutUint64(out[:8], uint64(i+1)) + return out +} + +func hashPair(left, right [32]byte) [32]byte { + var input [64]byte + copy(input[:32], left[:]) + copy(input[32:], right[:]) + return hash.Hash(input[:]) +} + +func referenceMerkleizeProgressive(chunks [][32]byte, numLeaves uint64) [32]byte { + if len(chunks) == 0 { + return [32]byte{} + } + if numLeaves == 0 { + panic("numLeaves must be positive") + } + + take := len(chunks) + if uint64(take) > numLeaves { + take = int(numLeaves) + } + left := slices.Clone(chunks[:take]) + a := ssz.MerkleizeVector(left, numLeaves) + b := referenceMerkleizeProgressive(chunks[take:], numLeaves*4) + return hashPair(a, b) +} + +func TestMerkleizeProgressiveChunks_MatchesReference(t *testing.T) { + testCases := []int{0, 1, 2, 3, 5, 6, 21, 22, 85, 86} + + for _, n := range testCases { + t.Run(fmt.Sprintf("len_%d", n), func(t *testing.T) { + chunks := make([][32]byte, n) + for i := range chunks { + chunks[i] = chunkFromIndex(i) + } + + expected := referenceMerkleizeProgressive(chunks, 1) + actual := ssz.MerkleizeProgressiveChunks(chunks) + require.Equal(t, expected, actual) + }) + } +} + +func TestMerkleizeVectorSSZProgressive(t *testing.T) { + elements := []staticHashable{ + {root: chunkFromIndex(0)}, + {root: chunkFromIndex(1)}, + {root: chunkFromIndex(2)}, + } + + root, err := ssz.MerkleizeVectorSSZProgressive(elements) + require.NoError(t, err) + + expected := ssz.MerkleizeProgressiveChunks([][32]byte{ + elements[0].root, + elements[1].root, + elements[2].root, + }) + require.Equal(t, expected, root) +} + +func TestMerkleizeVectorSSZProgressive_Error(t *testing.T) { + e := errors.New("merkleError") + elements := []staticHashable{{root: chunkFromIndex(0)}, {err: e}} + + _, err := ssz.MerkleizeVectorSSZProgressive(elements) + require.ErrorContains(t, "merkleError", err) +} + +func TestMerkleizeListSSZProgressive(t *testing.T) { + elements := []staticHashable{ + {root: chunkFromIndex(10)}, + {root: chunkFromIndex(11)}, + {root: chunkFromIndex(12)}, + } + + got, err := ssz.MerkleizeListSSZProgressive(elements) + require.NoError(t, err) + + body := ssz.MerkleizeProgressiveChunks([][32]byte{ + elements[0].root, + elements[1].root, + elements[2].root, + }) + var length [32]byte + binary.LittleEndian.PutUint64(length[:8], uint64(len(elements))) + expected := ssz.MixInLength(body, length[:]) + require.Equal(t, expected, got) +} + +func TestSliceRootProgressive(t *testing.T) { + elements := []staticHashable{{root: chunkFromIndex(0)}, {root: chunkFromIndex(1)}} + + sliceRoot, err := ssz.SliceRootProgressive(elements) + require.NoError(t, err) + listRoot, err := ssz.MerkleizeListSSZProgressive(elements) + require.NoError(t, err) + require.Equal(t, listRoot, sliceRoot) +} + +func TestByteSliceRootProgressive(t *testing.T) { + testCases := [][]byte{ + nil, + {}, + {0x01}, + {0x01, 0x02, 0x03}, + make([]byte, 32), + make([]byte, 33), + } + for _, input := range testCases { + t.Run(fmt.Sprintf("len_%d", len(input)), func(t *testing.T) { + got, err := ssz.ByteSliceRootProgressive(input) + require.NoError(t, err) + + var chunks [][32]byte + if len(input) > 0 { + chunks, err = ssz.PackByChunk([][]byte{input}) + require.NoError(t, err) + } + body := ssz.MerkleizeProgressiveChunks(chunks) + var length [32]byte + binary.LittleEndian.PutUint64(length[:8], uint64(len(input))) + expected := ssz.MixInLength(body, length[:]) + require.Equal(t, expected, got) + }) + } +} + +func TestByteSliceRootProgressive_EmptyReferenceRoot(t *testing.T) { + for _, input := range [][]byte{nil, {}} { + got, err := ssz.ByteSliceRootProgressive(input) + require.NoError(t, err) + // taken from remerkleable reference implementation: + require.Equal(t, "f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b", fmt.Sprintf("%x", got)) + } +} + +func TestMixInActiveFields(t *testing.T) { + root := chunkFromIndex(42) + activeFields := []bool{true, false, true, true, false, false, false, true, true} + + got, err := ssz.MixInActiveFields(root, activeFields) + require.NoError(t, err) + + var packed [32]byte + packed[0] = 0b10001101 + packed[1] = 0b00000001 + expected := hashPair(root, packed) + require.Equal(t, expected, got) +} + +func TestMixInActiveFields_TooMany(t *testing.T) { + activeFields := make([]bool, 257) + _, err := ssz.MixInActiveFields(chunkFromIndex(0), activeFields) + require.ErrorContains(t, "exceeds maximum 256", err) +} diff --git a/testing/endtoend/components/BUILD.bazel b/testing/endtoend/components/BUILD.bazel index 95744f230ddd..e2aaa58b06a7 100644 --- a/testing/endtoend/components/BUILD.bazel +++ b/testing/endtoend/components/BUILD.bazel @@ -31,6 +31,7 @@ go_library( "//config/params:go_default_library", "//crypto/bls:go_default_library", "//io/file:go_default_library", + "//network:go_default_library", "//proto/prysm/v1alpha1/validator-client:go_default_library", "//runtime/interop:go_default_library", "//testing/endtoend/helpers:go_default_library", diff --git a/testing/endtoend/components/beacon_node.go b/testing/endtoend/components/beacon_node.go index 6da1f1e93cc1..68d71f7e5552 100644 --- a/testing/endtoend/components/beacon_node.go +++ b/testing/endtoend/components/beacon_node.go @@ -275,6 +275,7 @@ func (node *BeaconNode) Start(ctx context.Context) error { "--" + cmdshared.ValidatorMonitorIndicesFlag.Name + "=2", "--" + cmdshared.ForceClearDB.Name, "--" + cmdshared.AcceptTosFlag.Name, + "--" + flags.Supernode.Name, } if config.UsePprof { args = append(args, "--pprof", fmt.Sprintf("--pprofport=%d", e2e.TestParams.Ports.PrysmBeaconNodePprofPort+index)) diff --git a/testing/endtoend/components/lighthouse_beacon.go b/testing/endtoend/components/lighthouse_beacon.go index 8de69d8ce7ef..a353b320bf71 100644 --- a/testing/endtoend/components/lighthouse_beacon.go +++ b/testing/endtoend/components/lighthouse_beacon.go @@ -13,6 +13,7 @@ import ( "github.com/OffchainLabs/prysm/v7/config/params" "github.com/OffchainLabs/prysm/v7/io/file" + prysmnetwork "github.com/OffchainLabs/prysm/v7/network" "github.com/OffchainLabs/prysm/v7/testing/endtoend/helpers" e2e "github.com/OffchainLabs/prysm/v7/testing/endtoend/params" e2etypes "github.com/OffchainLabs/prysm/v7/testing/endtoend/types" @@ -172,19 +173,25 @@ func (node *LighthouseBeaconNode) Start(ctx context.Context) error { prysmNodeCount := e2e.TestParams.BeaconNodeCount jwtPath := path.Join(e2e.TestParams.TestPath, "eth1data/"+strconv.Itoa(node.index+prysmNodeCount)+"/") jwtPath = path.Join(jwtPath, "geth/jwtsecret") + + enrAddress, err := prysmnetwork.ExternalIPv4() + if err != nil { + return fmt.Errorf("external ip v4: %w", err) + } + args := []string{ "beacon_node", fmt.Sprintf("--datadir=%s/lighthouse-beacon-node-%d", e2e.TestParams.TestPath, index), fmt.Sprintf("--testnet-dir=%s", testDir), "--staking", - "--enr-address=127.0.0.1", + fmt.Sprintf("--enr-address=%s", enrAddress), fmt.Sprintf("--enr-udp-port=%d", e2e.TestParams.Ports.LighthouseBeaconNodeP2PPort+index*2), // multiply by 2 because LH adds 1 for quic4 port fmt.Sprintf("--enr-tcp-port=%d", e2e.TestParams.Ports.LighthouseBeaconNodeP2PPort+index*2), // multiply by 2 because LH adds 1 for quic4 port fmt.Sprintf("--port=%d", e2e.TestParams.Ports.LighthouseBeaconNodeP2PPort+index*2), // multiply by 2 because LH adds 1 for quic4 port fmt.Sprintf("--http-port=%d", e2e.TestParams.Ports.LighthouseBeaconNodeHTTPPort+index), fmt.Sprintf("--target-peers=%d", 10), fmt.Sprintf("--execution-endpoint=http://127.0.0.1:%d", e2e.TestParams.Ports.Eth1ProxyPort+prysmNodeCount+index), - fmt.Sprintf("--jwt-secrets=%s", jwtPath), + fmt.Sprintf("--execution-jwt=%s", jwtPath), fmt.Sprintf("--boot-nodes=%s", node.enr), fmt.Sprintf("--metrics-port=%d", e2e.TestParams.Ports.LighthouseBeaconNodeMetricsPort+index), "--metrics", @@ -202,23 +209,23 @@ func (node *LighthouseBeaconNode) Start(ctx context.Context) error { args = append(args, fmt.Sprintf("--builder=%s:%d", "http://127.0.0.1", e2e.TestParams.Ports.Eth1ProxyPort+prysmNodeCount+index)) } cmd := exec.CommandContext(ctx, binaryPath, args...) /* #nosec G204 */ - // Write stderr to log files. - stderr, err := os.Create(path.Join(e2e.TestParams.LogPath, fmt.Sprintf("lighthouse_beacon_node_%d_stderr.log", index))) + logFile, err := os.Create(path.Join(e2e.TestParams.LogPath, fmt.Sprintf("lighthouse_beacon_node_%d.log", index))) if err != nil { return err } defer func() { - if err := stderr.Close(); err != nil { - log.WithError(err).Error("Failed to close stderr file") + if err := logFile.Close(); err != nil { + log.WithError(err).Error("Failed to close lighthouse beacon log file") } }() - cmd.Stderr = stderr + cmd.Stdout = logFile + cmd.Stderr = logFile log.Infof("Starting lighthouse beacon chain %d with flags: %s", index, strings.Join(args[2:], " ")) if err = cmd.Start(); err != nil { return fmt.Errorf("failed to start beacon node: %w", err) } - if err = helpers.WaitForTextInFile(stderr, "Metrics HTTP server started"); err != nil { + if err = helpers.WaitForTextInFile(logFile, "Metrics HTTP server started"); err != nil { return fmt.Errorf("could not find initialization for node %d, this means the node had issues starting: %w", index, err) } diff --git a/testing/endtoend/components/lighthouse_validator.go b/testing/endtoend/components/lighthouse_validator.go index c2bfc140fc41..2de30e8a1e4f 100644 --- a/testing/endtoend/components/lighthouse_validator.go +++ b/testing/endtoend/components/lighthouse_validator.go @@ -196,17 +196,17 @@ func (v *LighthouseValidatorNode) Start(ctx context.Context) error { cmd := exec.CommandContext(ctx, binaryPath, args...) // #nosec G204 -- Safe - // Write stderr to log files. - stderr, err := os.Create(path.Join(e2e.TestParams.LogPath, fmt.Sprintf("lighthouse_validator_%d_stderr.log", index))) + logFile, err := os.Create(path.Join(e2e.TestParams.LogPath, fmt.Sprintf("lighthouse_validator_%d.log", index))) if err != nil { return err } defer func() { - if err := stderr.Close(); err != nil { - log.WithError(err).Error("Failed to close stderr file") + if err := logFile.Close(); err != nil { + log.WithError(err).Error("Failed to close lighthouse validator log file") } }() - cmd.Stderr = stderr + cmd.Stdout = logFile + cmd.Stderr = logFile log.Infof("Starting lighthouse validator client %d with flags: %s %s", index, binaryPath, strings.Join(args, " ")) if err = cmd.Start(); err != nil { diff --git a/testing/endtoend/deps.bzl b/testing/endtoend/deps.bzl index 574a1b6d54bd..daa55e2a0b6d 100644 --- a/testing/endtoend/deps.bzl +++ b/testing/endtoend/deps.bzl @@ -1,6 +1,6 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") # gazelle:keep -lighthouse_version = "v7.0.0-beta.0" +lighthouse_version = "v8.2.0" lighthouse_archive_name = "lighthouse-%s-x86_64-unknown-linux-gnu.tar.gz" % lighthouse_version def e2e_deps(): @@ -14,7 +14,7 @@ def e2e_deps(): http_archive( name = "lighthouse", - integrity = "sha256-qMPifuh7u0epItu8DzZ8YdZ2fVZNW7WKnbmmAgjh/us=", + integrity = "sha256-w3IY/v+yoiVYtfDFGPzxS+i8PVUTMWLH014+UZNFFS0=", build_file = "@prysm//testing/endtoend:lighthouse.BUILD", url = ("https://github.com/sigp/lighthouse/releases/download/%s/" + lighthouse_archive_name) % lighthouse_version, ) diff --git a/testing/endtoend/mainnet_e2e_test.go b/testing/endtoend/mainnet_e2e_test.go index f7135ab1b633..c8fe47c28909 100644 --- a/testing/endtoend/mainnet_e2e_test.go +++ b/testing/endtoend/mainnet_e2e_test.go @@ -15,5 +15,25 @@ func TestEndToEnd_MainnetConfig_ValidatorAtCurrentRelease(t *testing.T) { } func TestEndToEnd_MainnetConfig_MultiClient(t *testing.T) { - e2eMainnet(t, false, true, types.InitForkCfg(version.Bellatrix, version.Electra, params.E2EMainnetTestConfig())).run() + const ( + electraForkEpoch = 0 + fuluForkEpoch = 2 + + BPO1ForkEpoch = fuluForkEpoch + 2 + BPO1MaxBlobsPerBlock = 15 + + BPO2ForkEpoch = fuluForkEpoch + 4 + BPO2MaxBlobsPerBlock = 21 + ) + + cfg := types.InitForkCfg(version.Electra, version.Fulu, params.E2EMainnetTestConfig()) + cfg.FuluForkEpoch = fuluForkEpoch + cfg.BlobSchedule = []params.BlobScheduleEntry{ + {Epoch: electraForkEpoch, MaxBlobsPerBlock: uint64(cfg.DeprecatedMaxBlobsPerBlockElectra)}, + {Epoch: BPO1ForkEpoch, MaxBlobsPerBlock: BPO1MaxBlobsPerBlock}, + {Epoch: BPO2ForkEpoch, MaxBlobsPerBlock: BPO2MaxBlobsPerBlock}, + } + cfg.InitializeForkSchedule() + + e2eMainnet(t, false, true, cfg, types.WithEpochs(8), types.WithLargeBlobs()).run() } diff --git a/validator/client/propose.go b/validator/client/propose.go index 9681cc7f41f2..dc9f8fbd5d30 100644 --- a/validator/client/propose.go +++ b/validator/client/propose.go @@ -8,6 +8,7 @@ import ( "github.com/OffchainLabs/prysm/v7/async" "github.com/OffchainLabs/prysm/v7/beacon-chain/core/signing" + "github.com/OffchainLabs/prysm/v7/config/features" fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams" "github.com/OffchainLabs/prysm/v7/config/params" "github.com/OffchainLabs/prysm/v7/config/proposer" @@ -59,6 +60,16 @@ func (v *validator) ProposeBlock(ctx context.Context, slot primitives.Slot, pubK span.SetAttributes(trace.StringAttribute("validator", fmtKey)) log := log.WithField("pubkey", fmt.Sprintf("%#x", bytesutil.Trunc(pubKey[:]))) + // Proposer timing games: optionally delay the block request into the slot so + // builders have more time to accrue MEV. Gated behind an experimental flag and + // clamped to remain before the attestation deadline (see proposalReleaseDelay). + if features.Get().EnableProposerTimingGames { + if delay := v.proposalReleaseDelay(slot); delay > 0 { + log.WithField("slot", slot).WithField("delay", delay).Debug("Delaying block proposal for timing games") + v.waitUntilSlotOffset(ctx, slot, delay) + } + } + // Sign randao reveal, it's used to request block from beacon node epoch := primitives.Epoch(slot / params.BeaconConfig().SlotsPerEpoch) randaoReveal, err := v.signRandaoReveal(ctx, pubKey, epoch, slot) diff --git a/validator/client/wait_helpers.go b/validator/client/wait_helpers.go index bda4112bbf5e..44dd2101c568 100644 --- a/validator/client/wait_helpers.go +++ b/validator/client/wait_helpers.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/OffchainLabs/prysm/v7/config/features" "github.com/OffchainLabs/prysm/v7/config/params" "github.com/OffchainLabs/prysm/v7/consensus-types/primitives" "github.com/OffchainLabs/prysm/v7/monitoring/tracing" @@ -12,6 +13,12 @@ import ( "github.com/OffchainLabs/prysm/v7/time/slots" ) +// proposerTimingGameSafetyBudget is the time reserved at the end of the +// timing-game window for the builder getHeader round-trip (~1s) plus block +// propagation, so that a delayed proposal still lands before the attestation +// deadline and the slot is not missed. +const proposerTimingGameSafetyBudget = 1500 * time.Millisecond + // slotComponentDeadline returns the absolute time corresponding to the provided slot component. func (v *validator) slotComponentDeadline(slot primitives.Slot, component primitives.BP) (time.Time, error) { startTime, err := slots.StartTime(v.genesisTime, slot) @@ -73,6 +80,68 @@ func (v *validator) waitForPayloadAvailableOrDeadline(ctx context.Context, slot } } +// waitUntilSlotOffset blocks until the given absolute offset into the slot has +// elapsed (slot start + offset), or the context is cancelled. It is used by the +// proposer timing-game path to release the block proposal request later in the slot. +func (v *validator) waitUntilSlotOffset(ctx context.Context, slot primitives.Slot, offset time.Duration) { + ctx, span := trace.StartSpan(ctx, "validator.waitProposerTimingGame") + defer span.End() + + startTime, err := slots.StartTime(v.genesisTime, slot) + if err != nil { + log.WithError(err).WithField("slot", slot).Error("Slot overflows, unable to wait for proposer timing-game delay") + return + } + wait := prysmTime.Until(startTime.Add(offset)) + if wait <= 0 { + return + } + t := time.NewTimer(wait) + defer t.Stop() + select { + case <-ctx.Done(): + tracing.AnnotateError(span, ctx.Err()) + return + case <-t.C: + return + } +} + +// proposalReleaseDelay returns the proposer timing-game delay to apply before +// releasing the block proposal request for slot. The configured delay is clamped +// so that the request, the builder getHeader round-trip and block propagation +// still complete before the attestation deadline (never missing the slot because +// of the delay). It logs a warning when the value is clamped, or when it is set +// beyond the honest-reorg-safe threshold. +func (v *validator) proposalReleaseDelay(slot primitives.Slot) time.Duration { + configured := features.Get().ProposerTimingGameDelay + if configured <= 0 { + return 0 + } + cfg := params.BeaconConfig() + // The attestation deadline is the point by which attesters vote on the head; + // the block must be requested early enough to fetch the builder bid and + // propagate before it. Use the fork-appropriate deadline. + dueBPS := cfg.AttestationDueBPS + if slots.ToEpoch(slot) >= cfg.GloasForkEpoch { + dueBPS = cfg.AttestationDueBPSGloas + } + maxDelay := cfg.SlotComponentDuration(dueBPS) - proposerTimingGameSafetyBudget + if maxDelay < 0 { + maxDelay = 0 + } + if configured > maxDelay { + log.WithField("configuredDelay", configured).WithField("clampedDelay", maxDelay). + Warn("Proposer timing-game delay exceeds the safe maximum before the attestation deadline; clamping to avoid a missed slot") + return maxDelay + } + if reorgCutoff := cfg.SlotComponentDuration(cfg.ProposerReorgCutoffBPS); configured > reorgCutoff { + log.WithField("configuredDelay", configured).WithField("reorgCutoff", reorgCutoff). + Warn("Proposer timing-game delay is beyond the honest-reorg-safe threshold; the block may be orphaned") + } + return configured +} + func (v *validator) slotComponentSpanName(component primitives.BP) string { cfg := params.BeaconConfig() switch component { diff --git a/validator/client/wait_helpers_test.go b/validator/client/wait_helpers_test.go index 102bfae1417d..c3513db531e4 100644 --- a/validator/client/wait_helpers_test.go +++ b/validator/client/wait_helpers_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/OffchainLabs/prysm/v7/config/features" "github.com/OffchainLabs/prysm/v7/config/params" "github.com/OffchainLabs/prysm/v7/consensus-types/primitives" "github.com/OffchainLabs/prysm/v7/testing/assert" @@ -85,3 +86,82 @@ func TestWaitUntilSlotComponent_ContextCancelReturnsImmediately(t *testing.T) { t.Fatal("waitUntilSlotComponent did not return after context cancellation") } } + +func TestWaitUntilSlotOffset_ReturnsImmediatelyWhenOffsetElapsed(t *testing.T) { + params.SetupTestConfigCleanup(t) + + // Genesis far enough in the past that slot 1's start + offset is already elapsed. + v := &validator{genesisTime: time.Now().Add(-time.Hour)} + + done := make(chan struct{}) + go func() { + v.waitUntilSlotOffset(context.Background(), 1, 1500*time.Millisecond) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("waitUntilSlotOffset did not return when the offset had already elapsed") + } +} + +func TestWaitUntilSlotOffset_ContextCancelReturnsImmediately(t *testing.T) { + params.SetupTestConfigCleanup(t) + + // Genesis in the future so the offset has not elapsed; only ctx cancel unblocks. + v := &validator{genesisTime: time.Now().Add(time.Hour)} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + done := make(chan struct{}) + go func() { + v.waitUntilSlotOffset(ctx, 1, 2*time.Second) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("waitUntilSlotOffset did not return after context cancellation") + } +} + +func TestProposalReleaseDelay(t *testing.T) { + params.SetupTestConfigCleanup(t) + // Pin the mainnet config: the bounds below assume the pre-Gloas attestation + // deadline, and other tests in this package activate Gloas globally. + params.OverrideBeaconConfig(params.MainnetConfig().Copy()) + cfg := params.BeaconConfig() + + v := &validator{genesisTime: time.Now()} + slot := primitives.Slot(5) + + // Compute the fork-appropriate bounds the same way the implementation does. + dueBPS := cfg.AttestationDueBPS + if slots.ToEpoch(slot) >= cfg.GloasForkEpoch { + dueBPS = cfg.AttestationDueBPSGloas + } + maxDelay := cfg.SlotComponentDuration(dueBPS) - proposerTimingGameSafetyBudget + reorgCutoff := cfg.SlotComponentDuration(cfg.ProposerReorgCutoffBPS) + require.Equal(t, true, maxDelay > 0) + require.Equal(t, true, reorgCutoff < maxDelay) + + tests := []struct { + name string + configured time.Duration + want time.Duration + }{ + {name: "disabled returns zero", configured: 0, want: 0}, + {name: "below cutoff passes through", configured: reorgCutoff - time.Second, want: reorgCutoff - time.Second}, + {name: "beyond reorg cutoff still allowed", configured: reorgCutoff + 100*time.Millisecond, want: reorgCutoff + 100*time.Millisecond}, + {name: "above max is clamped", configured: maxDelay + time.Second, want: maxDelay}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reset := features.InitWithReset(&features.Flags{ProposerTimingGameDelay: tt.configured}) + defer reset() + assert.Equal(t, tt.want, v.proposalReleaseDelay(slot)) + }) + } +}