Compare commits

...

108 Commits

Author SHA1 Message Date
aarshkshah1992
46b300c6ca increase crawl timeout 2026-01-07 19:11:45 +05:30
aarshkshah1992
04d45b6194 improve logging 2026-01-07 15:19:51 +05:30
aarshkshah1992
3e7cd8c2f1 protect one peer per topic 2026-01-06 21:31:05 +05:30
aarshkshah1992
ea1962bf17 better dialing policy 2026-01-06 20:54:18 +05:30
aarshkshah1992
6072e9c335 unit tests and better logging 2026-01-06 20:17:52 +05:30
aarshkshah1992
7d92cc5c32 do not prune sole provider peers 2026-01-06 19:26:20 +05:30
aarshkshah1992
4b8973289a better conn management 2026-01-06 18:40:54 +05:30
aarshkshah1992
51c64e75c0 add logging for topics 2026-01-06 18:22:40 +05:30
aarshkshah1992
d1ceff6621 fix CI 2026-01-06 12:37:33 +05:30
aarshkshah1992
e63c1bebfe resolve conflicts 2026-01-06 12:31:42 +05:30
Aarsh Shah
25becdcd33 Merge branch 'develop' into feat/gossipsub-control-pane-peer-crawler-peer-controller 2026-01-06 12:02:23 +05:30
aarshkshah1992
a2459aa365 address review 2026-01-05 14:59:41 +05:30
aarshkshah1992
80641dc3ae Add Status to P2P Service interface 2026-01-05 14:40:08 +05:30
aarshkshah1992
59bb4a8301 wait for p2p service to start 2026-01-05 14:35:37 +05:30
aarshkshah1992
08f117f04f better dial logic 2025-12-15 18:27:45 +04:00
aarshkshah1992
0b6365781f address review 2025-12-15 16:10:58 +04:00
aarshkshah1992
0c994445ea failed to ping node 2025-12-15 16:02:42 +04:00
aarshkshah1992
77c32203af removed seen check that is not needed 2025-12-15 15:59:07 +04:00
aarshkshah1992
743e6bab07 add a test for TestSubscriptionController_GetCurrentActiveTopicsWithMinPeerCount 2025-12-15 15:02:33 +04:00
aarshkshah1992
451d2a8bc5 per topic family dial policy 2025-12-15 14:46:39 +04:00
aarshkshah1992
6508bdfa9a fix bug in subscription 2025-12-15 12:25:12 +04:00
aarshkshah1992
d2bf512f36 remove pointer 2025-12-15 11:16:26 +04:00
aarshkshah1992
e62fe66b0a add metrics 2025-12-15 10:54:18 +04:00
aarshkshah1992
08fff3dec4 changes for fork watching 2025-12-15 09:53:03 +04:00
aarshkshah1992
75a3d45470 revert service dep 2025-12-12 18:38:59 +04:00
aarshkshah1992
bbd2d4da0f fix build 2025-12-12 16:28:36 +04:00
aarshkshah1992
378468c1ec fix flaky test 2025-12-12 16:06:57 +04:00
aarshkshah1992
25d375dbe0 change to control plane 2025-12-12 13:08:47 +04:00
aarshkshah1992
2d5f3112c8 merge develop 2025-12-12 13:03:29 +04:00
aarshkshah1992
1d49a2a88d change gossipsub to gossip 2025-12-12 12:57:29 +04:00
aarshkshah1992
b119290584 change gossipsub to gossip 2025-12-12 12:51:21 +04:00
aarshkshah1992
9b0cffcdea NSE changes 2025-12-12 12:39:04 +04:00
aarshkshah1992
979466a3d7 fix naming to subscription controller 2025-12-12 11:39:21 +04:00
aarshkshah1992
08ba8dd487 change interface names 2025-12-12 11:21:47 +04:00
aarshkshah1992
a235a581e1 remove service dep 2025-12-12 11:14:29 +04:00
aarshkshah1992
45b88de7f2 use beacon-chain config 2025-12-12 09:57:39 +04:00
aarshkshah1992
f69e017f6a add go-docs 2025-12-11 18:46:46 +04:00
aarshkshah1992
19e5684875 changes as per review 2025-12-11 18:33:43 +04:00
aarshkshah1992
24fc76b7fb changes as per review 2025-12-11 18:14:47 +04:00
aarshkshah1992
0a4aad543b address review 2025-12-11 17:11:15 +04:00
aarshkshah1992
ab8584d138 changes as per review 2025-12-11 16:28:16 +04:00
aarshkshah1992
9b6cd96012 changes as per review 2025-12-11 15:56:08 +04:00
aarshkshah1992
bba1358637 change data structures 2025-12-05 08:32:39 +04:00
aarshkshah1992
e6af417c62 add a go doc for the PeersForTopic function 2025-12-04 17:35:20 +04:00
aarshkshah1992
3509622323 fix comment 2025-12-04 17:20:08 +04:00
aarshkshah1992
80d7bd6084 address review 2025-12-04 17:01:27 +04:00
aarshkshah1992
8d3c3fd40b remove context cancellation comment 2025-12-04 16:58:10 +04:00
aarshkshah1992
1caf7074a7 fix error wrapping 2025-12-04 16:56:53 +04:00
Aarsh Shah
92bd155e3f Apply suggestions from code review
Co-authored-by: Manu NALEPA <enalepa@offchainlabs.com>
2025-12-04 16:50:24 +04:00
aarshkshah1992
8360b0f882 fix CI 2025-12-03 17:22:34 +04:00
aarshkshah1992
bc0d60138c run bazel gazelle 2025-12-03 17:12:48 +04:00
aarshkshah1992
32f377a665 fix crawl timing 2025-12-03 16:59:37 +04:00
aarshkshah1992
2ab792b0fe godocs 2025-12-03 16:50:13 +04:00
Aarsh Shah
2ae8de05dd Apply suggestions from code review
Co-authored-by: Manu NALEPA <enalepa@offchainlabs.com>
2025-12-03 16:27:07 +04:00
aarshkshah1992
cff96129b5 fix unsafe cast 2025-12-03 16:22:41 +04:00
aarshkshah1992
8abd1db7c1 change maxConcurrentDials to uint 2025-12-03 16:08:55 +04:00
aarshkshah1992
a4d726184f rename to p2p service 2025-12-03 16:02:19 +04:00
aarshkshah1992
85acf242f6 remove circular dependency 2025-12-03 16:00:29 +04:00
aarshkshah1992
ba6d1d0c6b update godoc 2025-12-03 13:34:41 +04:00
aarshkshah1992
69d3453f97 fix sync 2025-12-03 13:32:31 +04:00
aarshkshah1992
a69cf6f5d4 refactor update peers 2025-12-03 13:29:20 +04:00
aarshkshah1992
0a46c2d16d remove topic type 2025-12-03 13:12:01 +04:00
aarshkshah1992
3c1b8859bc rename functions 2025-12-03 12:40:25 +04:00
aarshkshah1992
9f645ae0a4 refactor update peer 2025-12-03 12:25:38 +04:00
aarshkshah1992
5eeeb9ed15 wrap error and remove nodeId 2025-12-03 11:06:24 +04:00
aarshkshah1992
2061fc8a2f design doc 2025-11-25 15:49:37 +04:00
aarshkshah1992
eb93350583 Merge remote-tracking branch 'origin/feat/gossipsub-control-pane-peer-crawler-peer-controller' into feat/gossipsub-control-pane-peer-crawler-peer-controller 2025-11-24 17:50:19 +04:00
aarshkshah1992
affdab7776 fix changelog 2025-11-24 17:49:47 +04:00
Aarsh Shah
eb556adab5 Merge branch 'develop' into feat/gossipsub-control-pane-peer-crawler-peer-controller 2025-11-24 17:47:11 +04:00
aarshkshah1992
09d886c676 fix test 2025-11-24 15:18:27 +04:00
aarshkshah1992
11c5c6fb8b fix dynamic families 2025-11-24 15:06:35 +04:00
aarshkshah1992
10804bbb56 fix conflicts 2025-11-24 14:58:09 +04:00
aarshkshah1992
e6f3b636ac fix lint 2025-11-24 14:54:23 +04:00
aarshkshah1992
e95676dd91 fix conflicts 2025-11-24 14:41:58 +04:00
aarshkshah1992
86b65e0912 re-run test 2025-11-21 17:10:35 +04:00
aarshkshah1992
e9dac06037 finish tests 2025-11-21 16:38:00 +04:00
aarshkshah1992
893cf60921 fix tests 2025-11-21 15:37:15 +04:00
aarshkshah1992
41c9f160a2 fix bazel 2025-11-21 14:08:02 +04:00
aarshkshah1992
707abe6112 fix mock peer manager 2025-11-21 14:06:36 +04:00
aarshkshah1992
76975a134d fix build 2025-11-21 14:00:40 +04:00
aarshkshah1992
672de432a2 finish all changes 2025-11-21 12:08:17 +04:00
aarshkshah1992
4a6d88d9fb finish all changes 2025-11-21 12:05:57 +04:00
aarshkshah1992
1ff836e549 peer controller first draft 2025-11-17 19:39:18 +04:00
aarshkshah1992
08f038fe80 fix lint 2025-11-13 18:57:18 +04:00
aarshkshah1992
63279bcadf revert new line change 2025-11-13 17:06:47 +04:00
aarshkshah1992
61628efd44 run CI 2025-11-13 17:04:41 +04:00
aarshkshah1992
9b07f13cd3 tests for the crawler 2025-11-13 16:58:49 +04:00
aarshkshah1992
1397a79b4c changelog 2025-11-12 17:09:33 +04:00
aarshkshah1992
929115639d draft 2025-11-12 16:38:07 +04:00
aarshkshah1992
14dca40786 draft 2025-11-12 16:36:53 +04:00
aarshkshah1992
fa7596bceb first draft 2025-11-12 12:54:41 +04:00
aarshkshah1992
5161f087fc fix compilation 2025-11-06 19:38:59 +04:00
aarshkshah1992
71050ab076 fix bazel 2025-11-06 19:10:19 +04:00
aarshkshah1992
614367ddcf fix lint 2025-11-05 22:26:55 +04:00
aarshkshah1992
3f7371445b fix test in sync 2025-11-05 22:23:26 +04:00
aarshkshah1992
a15a1ade17 fix test 2025-11-05 21:31:04 +04:00
aarshkshah1992
798376b1d7 fix bazel 2025-11-05 21:21:51 +04:00
aarshkshah1992
93271050bf more tests 2025-11-05 21:17:03 +04:00
aarshkshah1992
8dfbabc691 fork watcher test works 2025-11-05 14:45:35 +04:00
aarshkshah1992
af2522e5f0 fix schedule 2025-11-05 09:49:27 +04:00
aarshkshah1992
452d42bd10 fix test in sync 2025-11-05 09:21:34 +04:00
aarshkshah1992
3e985377ce fix test 2025-11-05 09:05:57 +04:00
aarshkshah1992
ab2e836d3f fix test 2025-11-04 21:11:45 +04:00
Aarsh Shah
14158bea9c Merge branch 'develop' into feat/use-topic-abstraction-for-gossipsub-and-refactor-fork-watcher 2025-11-04 12:02:25 -05:00
aarshkshah1992
e14590636f bazel gazelle 2025-11-04 20:08:41 +04:00
aarshkshah1992
ce3660d2e7 finish draft 2025-11-04 20:08:11 +04:00
aarshkshah1992
7853cb9db0 changelog fragment 2025-11-03 19:14:46 +04:00
aarshkshah1992
8cfeda1473 WIP gossipsub controller 2025-11-03 19:02:41 +04:00
43 changed files with 5675 additions and 1308 deletions

1
.gitignore vendored
View File

@@ -38,6 +38,7 @@ metaData
# execution API authentication
jwt.hex
execution/
# manual testing
tmp

View File

@@ -13,6 +13,8 @@ go_library(
"doc.go",
"fork.go",
"fork_watcher.go",
"gossip_peer_controller.go",
"gossip_peer_crawler.go",
"gossip_scoring_params.go",
"gossip_topic_mappings.go",
"handshake.go",
@@ -52,6 +54,7 @@ go_library(
"//beacon-chain/db:go_default_library",
"//beacon-chain/db/kv:go_default_library",
"//beacon-chain/p2p/encoder:go_default_library",
"//beacon-chain/p2p/gossipcrawler:go_default_library",
"//beacon-chain/p2p/peers:go_default_library",
"//beacon-chain/p2p/peers/peerdata:go_default_library",
"//beacon-chain/p2p/peers/scorers:go_default_library",
@@ -116,6 +119,7 @@ go_library(
"@com_github_prysmaticlabs_go_bitfield//:go_default_library",
"@com_github_sirupsen_logrus//:go_default_library",
"@org_golang_google_protobuf//proto:go_default_library",
"@org_golang_x_sync//semaphore:go_default_library",
],
)
@@ -129,6 +133,8 @@ go_test(
"dial_relay_node_test.go",
"discovery_test.go",
"fork_test.go",
"gossip_peer_controller_test.go",
"gossip_peer_crawler_test.go",
"gossip_scoring_params_test.go",
"gossip_topic_mappings_test.go",
"message_id_test.go",
@@ -155,9 +161,11 @@ go_test(
"//beacon-chain/core/helpers:go_default_library",
"//beacon-chain/core/peerdas:go_default_library",
"//beacon-chain/core/signing:go_default_library",
"//beacon-chain/db:go_default_library",
"//beacon-chain/db/iface:go_default_library",
"//beacon-chain/db/testing:go_default_library",
"//beacon-chain/p2p/encoder:go_default_library",
"//beacon-chain/p2p/gossipcrawler:go_default_library",
"//beacon-chain/p2p/peers:go_default_library",
"//beacon-chain/p2p/peers/peerdata:go_default_library",
"//beacon-chain/p2p/peers/scorers:go_default_library",
@@ -204,8 +212,10 @@ go_test(
"@com_github_libp2p_go_libp2p_pubsub//pb:go_default_library",
"@com_github_multiformats_go_multiaddr//:go_default_library",
"@com_github_pkg_errors//:go_default_library",
"@com_github_prometheus_client_golang//prometheus/testutil:go_default_library",
"@com_github_prysmaticlabs_go_bitfield//:go_default_library",
"@com_github_sirupsen_logrus//hooks/test:go_default_library",
"@com_github_stretchr_testify//require:go_default_library",
"@org_golang_google_protobuf//proto:go_default_library",
],
)

View File

@@ -61,7 +61,10 @@ func (s *Service) Broadcast(ctx context.Context, msg proto.Message) error {
if !ok {
return errors.Errorf("message of %T does not support marshaller interface", msg)
}
return s.broadcastObject(ctx, castMsg, fmt.Sprintf(topic, forkDigest))
fullTopic := fmt.Sprintf(topic, forkDigest) + s.Encoding().ProtocolSuffix()
return s.broadcastObject(ctx, castMsg, fullTopic)
}
// BroadcastAttestation broadcasts an attestation to the p2p network, the message is assumed to be
@@ -107,6 +110,7 @@ func (s *Service) BroadcastSyncCommitteeMessage(ctx context.Context, subnet uint
}
func (s *Service) internalBroadcastAttestation(ctx context.Context, subnet uint64, att ethpb.Att, forkDigest [fieldparams.VersionLength]byte) {
topic := AttestationSubnetTopic(forkDigest, subnet)
_, span := trace.StartSpan(ctx, "p2p.internalBroadcastAttestation")
defer span.End()
ctx = trace.NewContext(context.Background(), span) // clear parent context / deadline.
@@ -117,7 +121,7 @@ func (s *Service) internalBroadcastAttestation(ctx context.Context, subnet uint6
// Ensure we have peers with this subnet.
s.subnetLocker(subnet).RLock()
hasPeer := s.hasPeerWithSubnet(attestationToTopic(subnet, forkDigest))
hasPeer := s.hasPeerWithTopic(topic)
s.subnetLocker(subnet).RUnlock()
span.SetAttributes(
@@ -132,7 +136,7 @@ func (s *Service) internalBroadcastAttestation(ctx context.Context, subnet uint6
s.subnetLocker(subnet).Lock()
defer s.subnetLocker(subnet).Unlock()
if err := s.FindAndDialPeersWithSubnets(ctx, AttestationSubnetTopicFormat, forkDigest, minimumPeersPerSubnetForBroadcast, map[uint64]bool{subnet: true}); err != nil {
if err := s.gossipDialer.DialPeersForTopicBlocking(ctx, topic, minimumPeersPerSubnetForBroadcast); err != nil {
return errors.Wrap(err, "find peers with subnets")
}
@@ -154,13 +158,14 @@ func (s *Service) internalBroadcastAttestation(ctx context.Context, subnet uint6
return
}
if err := s.broadcastObject(ctx, att, attestationToTopic(subnet, forkDigest)); err != nil {
if err := s.broadcastObject(ctx, att, topic); err != nil {
log.WithError(err).Error("Failed to broadcast attestation")
tracing.AnnotateError(span, err)
}
}
func (s *Service) broadcastSyncCommittee(ctx context.Context, subnet uint64, sMsg *ethpb.SyncCommitteeMessage, forkDigest [fieldparams.VersionLength]byte) {
topic := SyncCommitteeSubnetTopic(forkDigest, subnet)
_, span := trace.StartSpan(ctx, "p2p.broadcastSyncCommittee")
defer span.End()
ctx = trace.NewContext(context.Background(), span) // clear parent context / deadline.
@@ -174,7 +179,7 @@ func (s *Service) broadcastSyncCommittee(ctx context.Context, subnet uint64, sMs
// to ensure that we can reuse the same subnet locker.
wrappedSubIdx := subnet + syncLockerVal
s.subnetLocker(wrappedSubIdx).RLock()
hasPeer := s.hasPeerWithSubnet(syncCommitteeToTopic(subnet, forkDigest))
hasPeer := s.hasPeerWithTopic(topic)
s.subnetLocker(wrappedSubIdx).RUnlock()
span.SetAttributes(
@@ -188,7 +193,7 @@ func (s *Service) broadcastSyncCommittee(ctx context.Context, subnet uint64, sMs
if err := func() error {
s.subnetLocker(wrappedSubIdx).Lock()
defer s.subnetLocker(wrappedSubIdx).Unlock()
if err := s.FindAndDialPeersWithSubnets(ctx, SyncCommitteeSubnetTopicFormat, forkDigest, minimumPeersPerSubnetForBroadcast, map[uint64]bool{subnet: true}); err != nil {
if err := s.gossipDialer.DialPeersForTopicBlocking(ctx, topic, minimumPeersPerSubnetForBroadcast); err != nil {
return errors.Wrap(err, "find peers with subnets")
}
@@ -206,7 +211,7 @@ func (s *Service) broadcastSyncCommittee(ctx context.Context, subnet uint64, sMs
return
}
if err := s.broadcastObject(ctx, sMsg, syncCommitteeToTopic(subnet, forkDigest)); err != nil {
if err := s.broadcastObject(ctx, sMsg, topic); err != nil {
log.WithError(err).Error("Failed to broadcast sync committee message")
tracing.AnnotateError(span, err)
}
@@ -234,6 +239,7 @@ func (s *Service) BroadcastBlob(ctx context.Context, subnet uint64, blob *ethpb.
}
func (s *Service) internalBroadcastBlob(ctx context.Context, subnet uint64, blobSidecar *ethpb.BlobSidecar, forkDigest [fieldparams.VersionLength]byte) {
topic := BlobSubnetTopic(forkDigest, subnet)
_, span := trace.StartSpan(ctx, "p2p.internalBroadcastBlob")
defer span.End()
ctx = trace.NewContext(context.Background(), span) // clear parent context / deadline.
@@ -244,7 +250,7 @@ func (s *Service) internalBroadcastBlob(ctx context.Context, subnet uint64, blob
wrappedSubIdx := subnet + blobSubnetLockerVal
s.subnetLocker(wrappedSubIdx).RLock()
hasPeer := s.hasPeerWithSubnet(blobSubnetToTopic(subnet, forkDigest))
hasPeer := s.hasPeerWithTopic(topic)
s.subnetLocker(wrappedSubIdx).RUnlock()
if !hasPeer {
@@ -253,7 +259,7 @@ func (s *Service) internalBroadcastBlob(ctx context.Context, subnet uint64, blob
s.subnetLocker(wrappedSubIdx).Lock()
defer s.subnetLocker(wrappedSubIdx).Unlock()
if err := s.FindAndDialPeersWithSubnets(ctx, BlobSubnetTopicFormat, forkDigest, minimumPeersPerSubnetForBroadcast, map[uint64]bool{subnet: true}); err != nil {
if err := s.gossipDialer.DialPeersForTopicBlocking(ctx, topic, minimumPeersPerSubnetForBroadcast); err != nil {
return errors.Wrap(err, "find peers with subnets")
}
@@ -265,7 +271,7 @@ func (s *Service) internalBroadcastBlob(ctx context.Context, subnet uint64, blob
}
}
if err := s.broadcastObject(ctx, blobSidecar, blobSubnetToTopic(subnet, forkDigest)); err != nil {
if err := s.broadcastObject(ctx, blobSidecar, topic); err != nil {
log.WithError(err).Error("Failed to broadcast blob sidecar")
tracing.AnnotateError(span, err)
}
@@ -294,7 +300,7 @@ func (s *Service) BroadcastLightClientOptimisticUpdate(ctx context.Context, upda
}
digest := params.ForkDigest(slots.ToEpoch(update.AttestedHeader().Beacon().Slot))
if err := s.broadcastObject(ctx, update, lcOptimisticToTopic(digest)); err != nil {
if err := s.broadcastObject(ctx, update, LcOptimisticToTopic(digest)); err != nil {
log.WithError(err).Debug("Failed to broadcast light client optimistic update")
err := errors.Wrap(err, "could not publish message")
tracing.AnnotateError(span, err)
@@ -328,7 +334,7 @@ func (s *Service) BroadcastLightClientFinalityUpdate(ctx context.Context, update
}
forkDigest := params.ForkDigest(slots.ToEpoch(update.AttestedHeader().Beacon().Slot))
if err := s.broadcastObject(ctx, update, lcFinalityToTopic(forkDigest)); err != nil {
if err := s.broadcastObject(ctx, update, LcFinalityToTopic(forkDigest)); err != nil {
log.WithError(err).Debug("Failed to broadcast light client finality update")
err := errors.Wrap(err, "could not publish message")
tracing.AnnotateError(span, err)
@@ -374,7 +380,7 @@ func (s *Service) broadcastDataColumnSidecars(ctx context.Context, forkDigest [f
topicFunc := func(sidecar blocks.VerifiedRODataColumn) (topic string, wrappedSubIdx uint64, subnet uint64) {
subnet = peerdas.ComputeSubnetForDataColumnSidecar(sidecar.Index)
topic = dataColumnSubnetToTopic(subnet, forkDigest)
topic = DataColumnSubnetTopic(forkDigest, subnet)
wrappedSubIdx = subnet + dataColumnSubnetVal
return
}
@@ -390,7 +396,7 @@ func (s *Service) broadcastDataColumnSidecars(ctx context.Context, forkDigest [f
// Check if we have a peer for this subnet (use RLock for read-only check).
mu := s.subnetLocker(wrappedSubIdx)
mu.RLock()
hasPeer := s.hasPeerWithSubnet(topic)
hasPeer := s.hasPeerWithTopic(topic)
mu.RUnlock()
if hasPeer {
@@ -433,10 +439,10 @@ func (s *Service) broadcastDataColumnSidecars(ctx context.Context, forkDigest [f
ctx := trace.NewContext(s.ctx, span)
defer span.End()
topic, wrappedSubIdx, subnet := topicFunc(sidecar)
topic, wrappedSubIdx, _ := topicFunc(sidecar)
// Find peers for this sidecar's subnet.
if err := s.findPeersIfNeeded(ctx, wrappedSubIdx, DataColumnSubnetTopicFormat, forkDigest, subnet); err != nil {
if err := s.findPeersIfNeeded(ctx, wrappedSubIdx, topic); err != nil {
tracing.AnnotateError(span, err)
log.WithError(err).Error("Cannot find peers if needed")
return
@@ -539,41 +545,37 @@ func (s *Service) broadcastDataColumnSidecars(ctx context.Context, forkDigest [f
func (s *Service) findPeersIfNeeded(
ctx context.Context,
wrappedSubIdx uint64,
topicFormat string,
forkDigest [fieldparams.VersionLength]byte,
subnet uint64,
topic string,
) error {
// Sending a data column sidecar to only one peer is not ideal,
// but it ensures at least one peer receives it.
s.subnetLocker(wrappedSubIdx).Lock()
defer s.subnetLocker(wrappedSubIdx).Unlock()
// No peers found, attempt to find peers with this subnet.
if err := s.FindAndDialPeersWithSubnets(ctx, topicFormat, forkDigest, minimumPeersPerSubnetForBroadcast, map[uint64]bool{subnet: true}); err != nil {
if err := s.gossipDialer.DialPeersForTopicBlocking(ctx, topic, minimumPeersPerSubnetForBroadcast); err != nil {
return errors.Wrap(err, "find peers with subnet")
}
return nil
}
// encodeGossipMessage encodes an object for gossip transmission.
// It returns the encoded bytes and the full topic with protocol suffix.
func (s *Service) encodeGossipMessage(obj ssz.Marshaler, topic string) ([]byte, string, error) {
func (s *Service) encodeGossipMessage(obj ssz.Marshaler) ([]byte, error) {
buf := new(bytes.Buffer)
if _, err := s.Encoding().EncodeGossip(buf, obj); err != nil {
return nil, "", fmt.Errorf("could not encode message: %w", err)
return nil, fmt.Errorf("could not encode message: %w", err)
}
return buf.Bytes(), topic + s.Encoding().ProtocolSuffix(), nil
return buf.Bytes(), nil
}
// broadcastObject broadcasts a message to other peers in our gossip mesh.
func (s *Service) broadcastObject(ctx context.Context, obj ssz.Marshaler, topic string) error {
func (s *Service) broadcastObject(ctx context.Context, obj ssz.Marshaler, fullTopic string) error {
ctx, span := trace.StartSpan(ctx, "p2p.broadcastObject")
defer span.End()
span.SetAttributes(trace.StringAttribute("topic", topic))
span.SetAttributes(trace.StringAttribute("topic", fullTopic))
data, fullTopic, err := s.encodeGossipMessage(obj, topic)
data, err := s.encodeGossipMessage(obj)
if err != nil {
tracing.AnnotateError(span, err)
return err
@@ -597,13 +599,13 @@ func (s *Service) broadcastObject(ctx context.Context, obj ssz.Marshaler, topic
// batchObject adds an object to a message batch for a future broadcast.
// The caller MUST publish the batch after all messages have been added.
func (s *Service) batchObject(ctx context.Context, batch *pubsub.MessageBatch, obj ssz.Marshaler, topic string) error {
func (s *Service) batchObject(ctx context.Context, batch *pubsub.MessageBatch, obj ssz.Marshaler, fullTopic string) error {
ctx, span := trace.StartSpan(ctx, "p2p.batchObject")
defer span.End()
span.SetAttributes(trace.StringAttribute("topic", topic))
span.SetAttributes(trace.StringAttribute("topic", fullTopic))
data, fullTopic, err := s.encodeGossipMessage(obj, topic)
data, err := s.encodeGossipMessage(obj)
if err != nil {
tracing.AnnotateError(span, err)
return err
@@ -624,27 +626,3 @@ func (s *Service) batchObject(ctx context.Context, batch *pubsub.MessageBatch, o
}
return nil
}
func attestationToTopic(subnet uint64, forkDigest [fieldparams.VersionLength]byte) string {
return fmt.Sprintf(AttestationSubnetTopicFormat, forkDigest, subnet)
}
func syncCommitteeToTopic(subnet uint64, forkDigest [fieldparams.VersionLength]byte) string {
return fmt.Sprintf(SyncCommitteeSubnetTopicFormat, forkDigest, subnet)
}
func blobSubnetToTopic(subnet uint64, forkDigest [fieldparams.VersionLength]byte) string {
return fmt.Sprintf(BlobSubnetTopicFormat, forkDigest, subnet)
}
func lcOptimisticToTopic(forkDigest [4]byte) string {
return fmt.Sprintf(LightClientOptimisticUpdateTopicFormat, forkDigest)
}
func lcFinalityToTopic(forkDigest [4]byte) string {
return fmt.Sprintf(LightClientFinalityUpdateTopicFormat, forkDigest)
}
func dataColumnSubnetToTopic(subnet uint64, forkDigest [fieldparams.VersionLength]byte) string {
return fmt.Sprintf(DataColumnSubnetTopicFormat, forkDigest, subnet)
}

View File

@@ -30,6 +30,7 @@ import (
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/OffchainLabs/prysm/v7/testing/util"
"github.com/OffchainLabs/prysm/v7/time/slots"
"github.com/ethereum/go-ethereum/p2p/enode"
pubsub "github.com/libp2p/go-libp2p-pubsub"
"github.com/libp2p/go-libp2p/core/host"
"github.com/libp2p/go-libp2p/core/peer"
@@ -111,6 +112,7 @@ func TestService_Attestation_Subnet(t *testing.T) {
if gtm := GossipTypeMapping[reflect.TypeFor[*ethpb.Attestation]()]; gtm != AttestationSubnetTopicFormat {
t.Errorf("Constant is out of date. Wanted %s, got %s", AttestationSubnetTopicFormat, gtm)
}
s := Service{}
tests := []struct {
att *ethpb.Attestation
@@ -123,7 +125,7 @@ func TestService_Attestation_Subnet(t *testing.T) {
Slot: 2,
},
},
topic: "/eth2/00000000/beacon_attestation_2",
topic: "/eth2/00000000/beacon_attestation_2" + s.Encoding().ProtocolSuffix(),
},
{
att: &ethpb.Attestation{
@@ -132,7 +134,7 @@ func TestService_Attestation_Subnet(t *testing.T) {
Slot: 10,
},
},
topic: "/eth2/00000000/beacon_attestation_21",
topic: "/eth2/00000000/beacon_attestation_21" + s.Encoding().ProtocolSuffix(),
},
{
att: &ethpb.Attestation{
@@ -141,12 +143,12 @@ func TestService_Attestation_Subnet(t *testing.T) {
Slot: 529,
},
},
topic: "/eth2/00000000/beacon_attestation_8",
topic: "/eth2/00000000/beacon_attestation_8" + s.Encoding().ProtocolSuffix(),
},
}
for _, tt := range tests {
subnet := helpers.ComputeSubnetFromCommitteeAndSlot(100, tt.att.Data.CommitteeIndex, tt.att.Data.Slot)
assert.Equal(t, tt.topic, attestationToTopic(subnet, [4]byte{} /* fork digest */), "Wrong topic")
assert.Equal(t, tt.topic, AttestationSubnetTopic([4]byte{}, subnet), "Wrong topic")
}
}
@@ -175,14 +177,12 @@ func TestService_BroadcastAttestation(t *testing.T) {
msg := util.HydrateAttestation(&ethpb.Attestation{AggregationBits: bitfield.NewBitlist(7)})
subnet := uint64(5)
topic := AttestationSubnetTopicFormat
GossipTypeMapping[reflect.TypeFor[*ethpb.Attestation]()] = topic
GossipTypeMapping[reflect.TypeFor[*ethpb.Attestation]()] = AttestationSubnetTopicFormat
digest, err := p.currentForkDigest()
require.NoError(t, err)
topic = fmt.Sprintf(topic, digest, subnet)
topic := AttestationSubnetTopic(digest, subnet)
// External peer subscribes to the topic.
topic += p.Encoding().ProtocolSuffix()
sub, err := p2.SubscribeToTopic(topic)
require.NoError(t, err)
@@ -228,6 +228,7 @@ func TestService_BroadcastAttestationWithDiscoveryAttempts(t *testing.T) {
// Setup bootnode.
cfg := &Config{PingInterval: testPingInterval, DB: db}
cfg.UDPPort = uint(port)
cfg.TCPPort = uint(port)
_, pkey := createAddrAndPrivKey(t)
ipAddr := net.ParseIP("127.0.0.1")
genesisTime := time.Now()
@@ -253,8 +254,9 @@ func TestService_BroadcastAttestationWithDiscoveryAttempts(t *testing.T) {
var listeners []*listenerWrapper
var hosts []host.Host
var configs []*Config
// setup other nodes.
cfg = &Config{
baseCfg := &Config{
Discv5BootStrapAddrs: []string{bootNode.String()},
MaxPeers: 2,
PingInterval: testPingInterval,
@@ -263,11 +265,21 @@ func TestService_BroadcastAttestationWithDiscoveryAttempts(t *testing.T) {
// Setup 2 different hosts
for i := uint(1); i <= 2; i++ {
h, pkey, ipAddr := createHost(t, port+i)
cfg.UDPPort = uint(port + i)
cfg.TCPPort = uint(port + i)
// Create a new config for each service to avoid shared mutations
cfg := &Config{
Discv5BootStrapAddrs: baseCfg.Discv5BootStrapAddrs,
MaxPeers: baseCfg.MaxPeers,
PingInterval: baseCfg.PingInterval,
DB: baseCfg.DB,
UDPPort: uint(port + i),
TCPPort: uint(port + i),
}
if len(listeners) > 0 {
cfg.Discv5BootStrapAddrs = append(cfg.Discv5BootStrapAddrs, listeners[len(listeners)-1].Self().String())
}
s := &Service{
cfg: cfg,
genesisTime: genesisTime,
@@ -280,18 +292,22 @@ func TestService_BroadcastAttestationWithDiscoveryAttempts(t *testing.T) {
close(s.custodyInfoSet)
listener, err := s.startDiscoveryV5(ipAddr, pkey)
// Set for 2nd peer
assert.NoError(t, err, "Could not start discovery for node")
// Set listener for the service
s.dv5Listener = listener
s.metaData = wrapper.WrappedMetadataV0(new(ethpb.MetaDataV0))
// Set subnet for 2nd peer
if i == 2 {
s.dv5Listener = listener
s.metaData = wrapper.WrappedMetadataV0(new(ethpb.MetaDataV0))
bitV := bitfield.NewBitvector64()
bitV.SetBitAt(subnet, true)
err := s.updateSubnetRecordWithMetadata(bitV)
require.NoError(t, err)
}
assert.NoError(t, err, "Could not start discovery for node")
listeners = append(listeners, listener)
hosts = append(hosts, h)
configs = append(configs, cfg)
}
defer func() {
// Close down all peers.
@@ -326,7 +342,7 @@ func TestService_BroadcastAttestationWithDiscoveryAttempts(t *testing.T) {
pubsub: ps1,
dv5Listener: listeners[0],
joinedTopics: map[string]*pubsub.Topic{},
cfg: cfg,
cfg: configs[0],
genesisTime: time.Now(),
genesisValidatorsRoot: bytesutil.PadTo([]byte{'A'}, 32),
subnetsLock: make(map[uint64]*sync.RWMutex),
@@ -342,7 +358,7 @@ func TestService_BroadcastAttestationWithDiscoveryAttempts(t *testing.T) {
pubsub: ps2,
dv5Listener: listeners[1],
joinedTopics: map[string]*pubsub.Topic{},
cfg: cfg,
cfg: configs[1],
genesisTime: time.Now(),
genesisValidatorsRoot: bytesutil.PadTo([]byte{'A'}, 32),
subnetsLock: make(map[uint64]*sync.RWMutex),
@@ -355,14 +371,12 @@ func TestService_BroadcastAttestationWithDiscoveryAttempts(t *testing.T) {
go p2.listenForNewNodes()
msg := util.HydrateAttestation(&ethpb.Attestation{AggregationBits: bitfield.NewBitlist(7)})
topic := AttestationSubnetTopicFormat
GossipTypeMapping[reflect.TypeFor[*ethpb.Attestation]()] = topic
GossipTypeMapping[reflect.TypeFor[*ethpb.Attestation]()] = AttestationSubnetTopicFormat
digest, err := p.currentForkDigest()
require.NoError(t, err)
topic = fmt.Sprintf(topic, digest, subnet)
topic := AttestationSubnetTopic(digest, subnet)
// External peer subscribes to the topic.
topic += p.Encoding().ProtocolSuffix()
// We don't use our internal subscribe method
// due to using floodsub over here.
tpHandle, err := p2.JoinTopic(topic)
@@ -433,14 +447,12 @@ func TestService_BroadcastSyncCommittee(t *testing.T) {
msg := util.HydrateSyncCommittee(&ethpb.SyncCommitteeMessage{})
subnet := uint64(5)
topic := SyncCommitteeSubnetTopicFormat
GossipTypeMapping[reflect.TypeFor[*ethpb.SyncCommitteeMessage]()] = topic
GossipTypeMapping[reflect.TypeFor[*ethpb.SyncCommitteeMessage]()] = SyncCommitteeSubnetTopicFormat
digest, err := p.currentForkDigest()
require.NoError(t, err)
topic = fmt.Sprintf(topic, digest, subnet)
topic := SyncCommitteeSubnetTopic(digest, subnet)
// External peer subscribes to the topic.
topic += p.Encoding().ProtocolSuffix()
sub, err := p2.SubscribeToTopic(topic)
require.NoError(t, err)
@@ -510,14 +522,12 @@ func TestService_BroadcastBlob(t *testing.T) {
}
subnet := uint64(0)
topic := BlobSubnetTopicFormat
GossipTypeMapping[reflect.TypeFor[*ethpb.BlobSidecar]()] = topic
GossipTypeMapping[reflect.TypeFor[*ethpb.BlobSidecar]()] = BlobSubnetTopicFormat
digest, err := p.currentForkDigest()
require.NoError(t, err)
topic = fmt.Sprintf(topic, digest, subnet)
topic := BlobSubnetTopic(digest, subnet)
// External peer subscribes to the topic.
topic += p.Encoding().ProtocolSuffix()
sub, err := p2.SubscribeToTopic(topic)
require.NoError(t, err)
@@ -577,10 +587,9 @@ func TestService_BroadcastLightClientOptimisticUpdate(t *testing.T) {
require.NoError(t, err)
GossipTypeMapping[reflect.TypeOf(msg)] = LightClientOptimisticUpdateTopicFormat
topic := fmt.Sprintf(LightClientOptimisticUpdateTopicFormat, params.ForkDigest(slots.ToEpoch(msg.AttestedHeader().Beacon().Slot)))
topic := LcOptimisticToTopic(params.ForkDigest(slots.ToEpoch(msg.AttestedHeader().Beacon().Slot)))
// External peer subscribes to the topic.
topic += p.Encoding().ProtocolSuffix()
sub, err := p2.SubscribeToTopic(topic)
require.NoError(t, err)
@@ -653,10 +662,9 @@ func TestService_BroadcastLightClientFinalityUpdate(t *testing.T) {
require.NoError(t, err)
GossipTypeMapping[reflect.TypeOf(msg)] = LightClientFinalityUpdateTopicFormat
topic := fmt.Sprintf(LightClientFinalityUpdateTopicFormat, params.ForkDigest(slots.ToEpoch(msg.AttestedHeader().Beacon().Slot)))
topic := LcFinalityToTopic(params.ForkDigest(slots.ToEpoch(msg.AttestedHeader().Beacon().Slot)))
// External peer subscribes to the topic.
topic += p.Encoding().ProtocolSuffix()
sub, err := p2.SubscribeToTopic(topic)
require.NoError(t, err)
@@ -704,7 +712,6 @@ func TestService_BroadcastDataColumn(t *testing.T) {
const (
port = 2000
columnIndex = 12
topicFormat = DataColumnSubnetTopicFormat
)
ctx := t.Context()
@@ -762,7 +769,17 @@ func TestService_BroadcastDataColumn(t *testing.T) {
require.NoError(t, err)
subnet := peerdas.ComputeSubnetForDataColumnSidecar(columnIndex)
topic := fmt.Sprintf(topicFormat, digest, subnet) + service.Encoding().ProtocolSuffix()
topic := DataColumnSubnetTopic(digest, subnet)
crawler, err := NewGossipPeerCrawler(t.Context(), service, listener, 1*time.Second, 1*time.Second, 10,
func(n *enode.Node) bool { return true },
service.Peers().Scorers().Score)
require.NoError(t, err)
err = crawler.Start(func(ctx context.Context, node *enode.Node) ([]string, error) {
return []string{topic}, nil
})
require.NoError(t, err)
service.gossipDialer = NewGossipPeerDialer(t.Context(), crawler, service.PubSub().ListPeers, service.DialPeers)
_, verifiedRoSidecars := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{{Index: columnIndex}})
verifiedRoSidecar := verifiedRoSidecars[0]

View File

@@ -369,11 +369,11 @@ func (s *Service) listenForNewNodes() {
}
}
// FindAndDialPeersWithSubnets ensures that our node is connected to enough peers.
// If, the threshold is met, then this function immediately returns.
// findAndDialPeers ensures that our node is connected to enough peers.
// If the threshold is met, then this function immediately returns.
// Otherwise, it searches for new peers and dials them.
// If `ctx is canceled while searching for peers, search is stopped, but new found peers are still dialed.
// In this case, the function returns an error.
// If `ctx` is canceled while searching for peers, search is stopped, but newly
// found peers are still dialed. In this case, the function returns an error.
func (s *Service) findAndDialPeers(ctx context.Context) error {
// Restrict dials if limit is applied.
maxConcurrentDials := math.MaxInt
@@ -404,8 +404,7 @@ func (s *Service) findAndDialPeers(ctx context.Context) error {
return err
}
dialedPeerCount := s.dialPeers(s.ctx, maxConcurrentDials, peersToDial)
dialedPeerCount := s.DialPeers(s.ctx, maxConcurrentDials, peersToDial)
if dialedPeerCount > missingPeerCount {
missingPeerCount = 0
continue
@@ -554,6 +553,7 @@ func (s *Service) createListener(
Bootnodes: bootNodes,
PingInterval: s.cfg.PingInterval,
NoFindnodeLivenessCheck: s.cfg.DisableLivenessCheck,
V5RespTimeout: 300 * time.Millisecond,
}
listener, err := discover.ListenV5(conn, localNode, dv5Cfg)

View File

@@ -0,0 +1,343 @@
package p2p
import (
"context"
"math"
"slices"
"sync"
"time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/gossipcrawler"
"github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags"
"github.com/ethereum/go-ethereum/p2p/enode"
"github.com/libp2p/go-libp2p/core/peer"
)
const dialInterval = 500 * time.Millisecond
const peerCountLogInterval = 5 * time.Minute
const topicMonitorInterval = 200 * time.Millisecond
// GossipPeerDialer maintains minimum peer counts for gossip topics by periodically
// dialing new peers discovered by a crawler. It runs a background loop that checks each
// topic's peer count and dials new peers when below the target threshold.
type GossipPeerDialer struct {
ctx context.Context
listPeers func(topic string) []peer.ID
dialPeers func(ctx context.Context, maxConcurrentDials int, nodes []*enode.Node) uint
crawler gossipcrawler.Crawler
topicsProvider gossipcrawler.SubnetTopicsProvider
cachedTopics map[string]int
once sync.Once
}
// NewGossipPeerDialer creates a new GossipPeerDialer instance.
//
// Parameters:
// - ctx: Parent context that controls the lifecycle of the dialer. When cancelled,
// the background dial loop will terminate.
// - crawler: Source of peer candidates for each topic. The crawler maintains a registry
// of peers discovered through DHT crawling, indexed by the topics they subscribe to.
// - listPeers: Function that returns the current peers connected for a given topic.
// Used to determine how many additional peers need to be dialed.
// - dialPeers: Function that dials the given enode.Node peers with a concurrency limit.
// Returns the number of successful dials.
//
// The dialer must be started with Start() before it begins maintaining peer counts.
func NewGossipPeerDialer(
ctx context.Context,
crawler gossipcrawler.Crawler,
listPeers func(topic string) []peer.ID,
dialPeers func(ctx context.Context, maxConcurrentDials int, nodes []*enode.Node) uint,
) *GossipPeerDialer {
return &GossipPeerDialer{
ctx: ctx,
listPeers: listPeers,
dialPeers: dialPeers,
crawler: crawler,
}
}
// Start begins the background dial loop that maintains peer counts for all topics.
//
// The provider function is called on each tick to get the current list of topics that
// need peer maintenance. This allows the set of topics to change dynamically as the node
// subscribes/unsubscribes from subnets.
//
// Start is idempotent - calling it multiple times has no effect after the first call.
// Only the provider from the first call will be used; subsequent calls are ignored.
//
// The dial loop runs every dialInterval (1 second) and for each topic:
// 1. Checks current peer count via listPeers()
// 2. If below the per-topic min peer count, requests candidates from the crawler
// 3. Deduplicates peers across all topics to avoid redundant dials
// 4. Dials missing peers with rate limiting if enabled
//
// Returns nil always (error return preserved for interface compatibility).
func (g *GossipPeerDialer) Start(provider gossipcrawler.SubnetTopicsProvider) error {
g.once.Do(func() {
g.topicsProvider = provider
g.cachedTopics = make(map[string]int)
go g.dialLoop()
go g.logPeerCountsLoop()
go g.topicMonitorLoop()
})
return nil
}
func (g *GossipPeerDialer) dialLoop() {
ticker := time.NewTicker(dialInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
peersToDial := g.selectPeersForTopics()
if len(peersToDial) == 0 {
continue
}
g.dialPeersWithRatelimiting(peersToDial)
case <-g.ctx.Done():
return
}
}
}
func (g *GossipPeerDialer) logPeerCountsLoop() {
ticker := time.NewTicker(peerCountLogInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
topics := g.topicsProvider()
for topic, minPeers := range topics {
currentPeers := len(g.listPeers(topic))
log.WithField("topic", topic).
WithField("currentPeers", currentPeers).
WithField("minPeers", minPeers).
Debug("Gossip topic peer count")
}
case <-g.ctx.Done():
return
}
}
}
// topicsChanged compares the new topics with cached topics and returns true
// if topics have been added or removed. Changes to min peer counts are ignored.
func (g *GossipPeerDialer) topicsChanged(newTopics map[string]int) bool {
if len(newTopics) != len(g.cachedTopics) {
return true
}
for topic := range newTopics {
if _, ok := g.cachedTopics[topic]; !ok {
return true
}
}
return false
}
func (g *GossipPeerDialer) topicMonitorLoop() {
ticker := time.NewTicker(topicMonitorInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
topics := g.topicsProvider()
if g.topicsChanged(topics) {
g.cachedTopics = topics
g.crawler.TriggerCrawl()
}
case <-g.ctx.Done():
return
}
}
}
// selectPeersForTopics builds a bidirectional mapping of topics to peers and selects
// peers to dial using a greedy algorithm that prioritizes peers serving multiple topics.
// When a peer is selected, the needed count is decremented for ALL topics that peer serves,
// avoiding redundant dials when one peer can satisfy multiple topic requirements.
func (g *GossipPeerDialer) selectPeersForTopics() []*enode.Node {
topicsWithMinPeers := g.topicsProvider()
// Calculate how many peers each topic still needs.
neededByTopic := make(map[string]int)
for topic, minPeers := range topicsWithMinPeers {
currentCount := len(g.listPeers(topic))
if needed := minPeers - currentCount; needed > 0 {
neededByTopic[topic] = needed
}
}
if len(neededByTopic) == 0 {
return nil
}
peerToTopics := make(map[enode.ID][]string)
nodeByID := make(map[enode.ID]*enode.Node)
for topic := range neededByTopic {
candidates := g.crawler.PeersForTopic(topic)
for _, node := range candidates {
id := node.ID()
if _, exists := nodeByID[id]; !exists {
nodeByID[id] = node
}
peerToTopics[id] = append(peerToTopics[id], topic)
}
}
// Build candidate list sorted by topic count (descending).
// Peers serving more topics are prioritized.
type candidate struct {
node *enode.Node
topics []string
}
candidates := make([]candidate, 0, len(peerToTopics))
for id, topics := range peerToTopics {
candidates = append(candidates, candidate{node: nodeByID[id], topics: topics})
}
// sort candidates by topic count (descending)
slices.SortFunc(candidates, func(a, b candidate) int {
return len(b.topics) - len(a.topics)
})
// Greedy selection with cross-topic accounting.
var selected []*enode.Node
for _, c := range candidates {
// Check if this peer serves any topic we still need.
servesNeededTopic := false
for _, topic := range c.topics {
if neededByTopic[topic] > 0 {
servesNeededTopic = true
break
}
}
if !servesNeededTopic {
continue
}
// Select this peer and decrement needed count for ALL topics it serves.
selected = append(selected, c.node)
for _, topic := range c.topics {
if neededByTopic[topic] > 0 {
neededByTopic[topic]--
}
}
}
return selected
}
// DialPeersForTopicBlocking blocks until the specified topic has at least nPeers connected,
// or until the context is cancelled.
//
// This method is useful when you need to ensure a minimum number of peers are connected
// for a specific topic before proceeding (e.g., before publishing a message).
//
// The method polls in a loop:
// 1. Check if current peer count >= nPeers, return nil if satisfied
// 2. Get peer candidates from crawler for this topic
// 3. Dial candidates with rate limiting
// 4. Wait 100ms for connections to establish in pubsub layer
// 5. Repeat until target reached or context cancelled
//
// Parameters:
// - ctx: Context to cancel the blocking operation. Takes precedence for cancellation.
// - topic: The gossipsub topic to ensure peers for.
// - nPeers: Minimum number of peers required before returning.
//
// Returns:
// - nil: Successfully reached the target peer count.
// - ctx.Err(): The provided context was cancelled.
// - g.ctx.Err(): The dialer's parent context was cancelled.
//
// Note: This may block indefinitely if the crawler cannot provide enough peers
// and the context has no deadline.
func (g *GossipPeerDialer) DialPeersForTopicBlocking(ctx context.Context, topic string, nPeers int) error {
for {
peers := g.listPeers(topic)
if len(peers) >= nPeers {
return nil
}
newPeers := g.peersForTopic(topic, nPeers)
if len(newPeers) > 0 {
g.dialPeersWithRatelimiting(newPeers)
}
select {
case <-ctx.Done():
return ctx.Err()
// some wait here is good after dialing as connections take some time to show up in pubsub
case <-time.After(100 * time.Millisecond):
case <-g.ctx.Done():
return g.ctx.Err()
}
}
}
func (g *GossipPeerDialer) peersForTopic(topic string, targetCount int) []*enode.Node {
peers := g.listPeers(topic)
peerCount := len(peers)
if peerCount >= targetCount {
return nil
}
missing := targetCount - peerCount
newPeers := g.crawler.PeersForTopic(topic)
if len(newPeers) > missing {
newPeers = newPeers[:missing]
}
return newPeers
}
// ProtectedPeers returns peer IDs that should be protected from pruning.
// For each topic, one connected peer is marked as protected to ensure
// we maintain connectivity to all subscribed topics.
func (g *GossipPeerDialer) ProtectedPeers() []peer.ID {
if g.topicsProvider == nil {
return nil
}
topics := g.topicsProvider()
protectedPeers := make(map[peer.ID]struct{})
for topic := range topics {
connectedPeers := g.listPeers(topic)
// Skip if no peers connected
if len(connectedPeers) == 0 {
continue
}
// Protect the first peer for this topic
protectedPeers[connectedPeers[0]] = struct{}{}
}
result := make([]peer.ID, 0, len(protectedPeers))
for pid := range protectedPeers {
result = append(result, pid)
}
return result
}
func (g *GossipPeerDialer) dialPeersWithRatelimiting(peers []*enode.Node) {
// Dial new peers in batches.
maxConcurrentDials := math.MaxInt
if flags.MaxDialIsActive() {
maxConcurrentDials = flags.Get().MaxConcurrentDials
}
g.dialPeers(g.ctx, maxConcurrentDials, peers)
}

View File

@@ -0,0 +1,691 @@
package p2p
import (
"context"
"crypto/rand"
"net"
"slices"
"sync"
"testing"
"time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/gossipcrawler"
"github.com/OffchainLabs/prysm/v7/crypto/ecdsa"
"github.com/ethereum/go-ethereum/p2p/enode"
"github.com/ethereum/go-ethereum/p2p/enr"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/stretchr/testify/require"
)
func TestGossipPeerDialer_Start(t *testing.T) {
tests := []struct {
name string
newCrawler func(t *testing.T) *mockCrawler
provider gossipcrawler.SubnetTopicsProvider
expectedConnects int
expectStartErr bool
}{
{
name: "dials unique peers across topics",
newCrawler: func(t *testing.T) *mockCrawler {
nodeA := newTestNode(t, "127.0.0.1", 30101)
nodeB := newTestNode(t, "127.0.0.1", 30102)
return &mockCrawler{
consume: true,
peers: map[string][]*enode.Node{
"topic/a": {nodeA, nodeB},
"topic/b": {nodeA},
},
}
},
provider: func() map[string]int {
return map[string]int{"topic/a": 2, "topic/b": 2}
},
expectedConnects: 2,
},
{
name: "uses per-topic min peer counts",
newCrawler: func(t *testing.T) *mockCrawler {
nodes := make([]*enode.Node, 5)
for i := range nodes {
nodes[i] = newTestNode(t, "127.0.0.1", uint16(30110+i))
}
return &mockCrawler{
consume: true,
peers: map[string][]*enode.Node{
// topic/mesh has 3 available peers, minPeers=2 -> should dial 2
"topic/mesh": {nodes[0], nodes[1], nodes[2]},
// topic/fanout has 3 available peers, minPeers=1 -> should dial 1
"topic/fanout": {nodes[3], nodes[4]},
},
}
},
provider: func() map[string]int {
return map[string]int{
"topic/mesh": 2,
"topic/fanout": 1,
}
},
// Total: 2 from mesh + 1 from fanout = 3 peers dialed
expectedConnects: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
md := &mockDialer{}
listPeers := func(topic string) []peer.ID { return nil }
dialer := NewGossipPeerDialer(t.Context(), tt.newCrawler(t), listPeers, md.DialPeers)
err := dialer.Start(tt.provider)
if tt.expectStartErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Eventually(t, func() bool {
return md.dialCount() >= tt.expectedConnects
}, 2*time.Second, 20*time.Millisecond)
require.Equal(t, tt.expectedConnects, md.dialCount())
})
}
}
func TestGossipPeerDialer_DialPeersForTopicBlocking(t *testing.T) {
tests := []struct {
name string
connectedPeers int
newCrawler func(t *testing.T) *mockCrawler
targetPeers int
ctx func() (context.Context, context.CancelFunc)
expectedConnects int
expectErr bool
}{
{
name: "returns immediately when enough peers",
connectedPeers: 1,
newCrawler: func(t *testing.T) *mockCrawler {
return &mockCrawler{}
},
targetPeers: 1,
ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
expectedConnects: 0,
expectErr: false,
},
{
name: "dials when peers are missing",
connectedPeers: 0,
newCrawler: func(t *testing.T) *mockCrawler {
nodeA := newTestNode(t, "127.0.0.1", 30201)
nodeB := newTestNode(t, "127.0.0.1", 30202)
return &mockCrawler{
peers: map[string][]*enode.Node{
"topic/a": {nodeA, nodeB},
},
}
},
targetPeers: 2,
ctx: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 1*time.Second)
},
expectedConnects: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
md := &mockDialer{}
var mu sync.Mutex
connected := make([]peer.ID, 0)
for i := 0; i < tt.connectedPeers; i++ {
connected = append(connected, peer.ID(string(rune(i))))
}
listPeers := func(topic string) []peer.ID {
mu.Lock()
defer mu.Unlock()
return connected
}
dialPeers := func(ctx context.Context, max int, nodes []*enode.Node) uint {
cnt := md.DialPeers(ctx, max, nodes)
mu.Lock()
defer mu.Unlock()
for range nodes {
// Just add a dummy peer ID to simulate connection success
connected = append(connected, peer.ID("dummy"))
}
return cnt
}
crawler := tt.newCrawler(t)
dialer := NewGossipPeerDialer(t.Context(), crawler, listPeers, dialPeers)
topic := "topic/a"
ctx, cancel := tt.ctx()
defer cancel()
err := dialer.DialPeersForTopicBlocking(ctx, topic, tt.targetPeers)
if tt.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.expectedConnects, md.dialCount())
})
}
}
func TestGossipPeerDialer_peersForTopic(t *testing.T) {
tests := []struct {
name string
connected int
targetCount int
buildPeers func(t *testing.T) ([]*enode.Node, []*enode.Node)
}{
{
name: "returns nil when enough peers already connected",
connected: 1,
targetCount: 1,
buildPeers: func(t *testing.T) ([]*enode.Node, []*enode.Node) {
return []*enode.Node{newTestNode(t, "127.0.0.1", 30301)}, nil
},
},
{
name: "returns crawler peers when none connected",
connected: 0,
targetCount: 2,
buildPeers: func(t *testing.T) ([]*enode.Node, []*enode.Node) {
nodeA := newTestNode(t, "127.0.0.1", 30311)
nodeB := newTestNode(t, "127.0.0.1", 30312)
return []*enode.Node{nodeA, nodeB}, []*enode.Node{nodeA, nodeB}
},
},
{
name: "truncates peers when more than needed",
connected: 0,
targetCount: 1,
buildPeers: func(t *testing.T) ([]*enode.Node, []*enode.Node) {
nodeA := newTestNode(t, "127.0.0.1", 30321)
nodeB := newTestNode(t, "127.0.0.1", 30322)
nodeC := newTestNode(t, "127.0.0.1", 30323)
return []*enode.Node{nodeA, nodeB, nodeC}, []*enode.Node{nodeA}
},
},
{
name: "only returns missing peers",
connected: 1,
targetCount: 3,
buildPeers: func(t *testing.T) ([]*enode.Node, []*enode.Node) {
nodeA := newTestNode(t, "127.0.0.1", 30331)
nodeB := newTestNode(t, "127.0.0.1", 30332)
nodeC := newTestNode(t, "127.0.0.1", 30333)
return []*enode.Node{nodeA, nodeB, nodeC}, []*enode.Node{nodeA, nodeB}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
listPeers := func(topic string) []peer.ID {
peers := make([]peer.ID, tt.connected)
for i := 0; i < tt.connected; i++ {
peers[i] = peer.ID(string(rune(i))) // Fake peer ID
}
return peers
}
crawlerPeers, expected := tt.buildPeers(t)
crawler := &mockCrawler{
peers: map[string][]*enode.Node{"topic/test": crawlerPeers},
consume: false,
}
dialer := NewGossipPeerDialer(t.Context(), crawler, listPeers, func(ctx context.Context,
maxConcurrentDials int, nodes []*enode.Node) uint {
return 0
})
got := dialer.peersForTopic("topic/test", tt.targetCount)
if expected == nil {
require.Nil(t, got)
return
}
require.Equal(t, len(expected), len(got))
for i := range expected {
require.Equal(t, expected[i], got[i])
}
})
}
}
func TestGossipPeerDialer_selectPeersForTopics(t *testing.T) {
tests := []struct {
name string
connectedPeers map[string]int // topic -> connected peer count
topicsProvider func() map[string]int
buildPeers func(t *testing.T) (map[string][]*enode.Node, []*enode.Node)
}{
{
name: "prioritizes multi-topic peer over single-topic peers",
connectedPeers: map[string]int{},
topicsProvider: func() map[string]int {
return map[string]int{
"topic/a": 1,
"topic/b": 1,
"topic/c": 1,
}
},
buildPeers: func(t *testing.T) (map[string][]*enode.Node, []*enode.Node) {
// Peer X serves all 3 topics
nodeX := newTestNode(t, "127.0.0.1", 30401)
// Peer Y serves only topic/a
nodeY := newTestNode(t, "127.0.0.1", 30402)
// Peer Z serves only topic/b
nodeZ := newTestNode(t, "127.0.0.1", 30403)
crawlerPeers := map[string][]*enode.Node{
"topic/a": {nodeX, nodeY},
"topic/b": {nodeX, nodeZ},
"topic/c": {nodeX},
}
// Only nodeX should be dialed (satisfies all 3 topics)
return crawlerPeers, []*enode.Node{nodeX}
},
},
{
name: "cross-topic decrement works correctly",
connectedPeers: map[string]int{},
topicsProvider: func() map[string]int {
return map[string]int{
"topic/a": 2, // Need 2 peers
"topic/b": 1, // Need 1 peer
}
},
buildPeers: func(t *testing.T) (map[string][]*enode.Node, []*enode.Node) {
// Peer X serves both topics
nodeX := newTestNode(t, "127.0.0.1", 30411)
// Peer Y serves only topic/a
nodeY := newTestNode(t, "127.0.0.1", 30412)
crawlerPeers := map[string][]*enode.Node{
"topic/a": {nodeX, nodeY},
"topic/b": {nodeX},
}
// nodeX covers topic/b fully, and 1 of 2 for topic/a
// nodeY covers remaining 1 for topic/a
return crawlerPeers, []*enode.Node{nodeX, nodeY}
},
},
{
name: "no redundant dials when one peer satisfies all",
connectedPeers: map[string]int{},
topicsProvider: func() map[string]int {
return map[string]int{
"topic/a": 1,
"topic/b": 1,
"topic/c": 1,
}
},
buildPeers: func(t *testing.T) (map[string][]*enode.Node, []*enode.Node) {
nodeX := newTestNode(t, "127.0.0.1", 30421)
crawlerPeers := map[string][]*enode.Node{
"topic/a": {nodeX},
"topic/b": {nodeX},
"topic/c": {nodeX},
}
// Only 1 dial needed for all 3 topics
return crawlerPeers, []*enode.Node{nodeX}
},
},
{
name: "skips topics with enough peers already",
connectedPeers: map[string]int{
"topic/a": 2, // Already has 2
},
topicsProvider: func() map[string]int {
return map[string]int{
"topic/a": 2, // min 2, already have 2
"topic/b": 1, // min 1, have 0
}
},
buildPeers: func(t *testing.T) (map[string][]*enode.Node, []*enode.Node) {
nodeX := newTestNode(t, "127.0.0.1", 30431)
nodeY := newTestNode(t, "127.0.0.1", 30432)
crawlerPeers := map[string][]*enode.Node{
"topic/a": {nodeX},
"topic/b": {nodeY},
}
// Only nodeY should be dialed (topic/a already satisfied)
return crawlerPeers, []*enode.Node{nodeY}
},
},
{
name: "returns nil when all topics satisfied",
connectedPeers: map[string]int{"topic/a": 2, "topic/b": 1},
topicsProvider: func() map[string]int {
return map[string]int{
"topic/a": 2,
"topic/b": 1,
}
},
buildPeers: func(t *testing.T) (map[string][]*enode.Node, []*enode.Node) {
nodeX := newTestNode(t, "127.0.0.1", 30441)
crawlerPeers := map[string][]*enode.Node{
"topic/a": {nodeX},
"topic/b": {nodeX},
}
// No dials needed
return crawlerPeers, nil
},
},
{
name: "handles empty crawler response",
connectedPeers: map[string]int{},
topicsProvider: func() map[string]int {
return map[string]int{"topic/a": 1}
},
buildPeers: func(t *testing.T) (map[string][]*enode.Node, []*enode.Node) {
return map[string][]*enode.Node{}, nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
listPeers := func(topic string) []peer.ID {
count := tt.connectedPeers[topic]
peers := make([]peer.ID, count)
for i := range count {
peers[i] = peer.ID(topic + string(rune(i)))
}
return peers
}
crawlerPeers, expected := tt.buildPeers(t)
crawler := &mockCrawler{
peers: crawlerPeers,
consume: false,
}
dialer := NewGossipPeerDialer(t.Context(), crawler, listPeers, func(ctx context.Context,
maxConcurrentDials int, nodes []*enode.Node) uint {
return 0
})
dialer.topicsProvider = tt.topicsProvider
got := dialer.selectPeersForTopics()
if expected == nil {
require.Nil(t, got)
return
}
require.Equal(t, len(expected), len(got), "expected %d peers, got %d", len(expected), len(got))
// Verify all expected nodes are present (order may vary for equal topic counts)
expectedIDs := make(map[enode.ID]struct{})
for _, n := range expected {
expectedIDs[n.ID()] = struct{}{}
}
for _, n := range got {
_, ok := expectedIDs[n.ID()]
require.True(t, ok, "unexpected peer %s in result", n.ID())
}
})
}
}
func TestGossipPeerDialer_ProtectedPeers(t *testing.T) {
peerA := peer.ID("peerA")
peerB := peer.ID("peerB")
peerC := peer.ID("peerC")
tests := []struct {
name string
topicsProvider func() map[string]int
connectedPeers map[string][]peer.ID // topic -> connected peers
expected []peer.ID
}{
{
name: "nil topics provider",
topicsProvider: nil,
connectedPeers: map[string][]peer.ID{},
expected: nil,
},
{
name: "no topics",
topicsProvider: func() map[string]int { return map[string]int{} },
connectedPeers: map[string][]peer.ID{},
expected: []peer.ID{},
},
{
name: "no peers for any topic",
topicsProvider: func() map[string]int { return map[string]int{"topic/a": 1, "topic/b": 1} },
connectedPeers: map[string][]peer.ID{"topic/a": {}, "topic/b": {}},
expected: []peer.ID{},
},
{
name: "multiple peers for all topics protects first peer from each",
topicsProvider: func() map[string]int { return map[string]int{"topic/a": 2, "topic/b": 2} },
connectedPeers: map[string][]peer.ID{"topic/a": {peerA, peerB}, "topic/b": {peerB, peerC}},
expected: []peer.ID{peerA, peerB},
},
{
name: "single peer for one topic",
topicsProvider: func() map[string]int { return map[string]int{"topic/a": 1} },
connectedPeers: map[string][]peer.ID{"topic/a": {peerA}},
expected: []peer.ID{peerA},
},
{
name: "same peer is first for multiple topics",
topicsProvider: func() map[string]int { return map[string]int{"topic/a": 1, "topic/b": 1} },
connectedPeers: map[string][]peer.ID{"topic/a": {peerA}, "topic/b": {peerA}},
expected: []peer.ID{peerA},
},
{
name: "different first peers for different topics",
topicsProvider: func() map[string]int { return map[string]int{"topic/a": 1, "topic/b": 1} },
connectedPeers: map[string][]peer.ID{"topic/a": {peerA}, "topic/b": {peerB}},
expected: []peer.ID{peerA, peerB},
},
{
name: "protects first peer from each topic",
topicsProvider: func() map[string]int { return map[string]int{"topic/a": 1, "topic/b": 2, "topic/c": 1} },
connectedPeers: map[string][]peer.ID{"topic/a": {peerA}, "topic/b": {peerB, peerC}, "topic/c": {peerC}},
expected: []peer.ID{peerA, peerB, peerC},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
listPeers := func(topic string) []peer.ID {
return tt.connectedPeers[topic]
}
dialer := &GossipPeerDialer{
topicsProvider: tt.topicsProvider,
listPeers: listPeers,
}
got := dialer.ProtectedPeers()
if tt.expected == nil {
require.Nil(t, got)
return
}
require.NotNil(t, got)
require.Equal(t, len(tt.expected), len(got), "expected %d peers, got %d", len(tt.expected), len(got))
if len(tt.expected) == 0 {
return
}
// Check all expected peers are present (order may vary due to map iteration)
expectedSet := make(map[peer.ID]struct{})
for _, p := range tt.expected {
expectedSet[p] = struct{}{}
}
for _, p := range got {
_, ok := expectedSet[p]
require.True(t, ok, "unexpected peer %s in result", p)
}
})
}
}
func TestGossipPeerDialer_topicsChanged(t *testing.T) {
tests := []struct {
name string
cachedTopics map[string]int
newTopics map[string]int
expected bool
}{
{
name: "both empty",
cachedTopics: map[string]int{},
newTopics: map[string]int{},
expected: false,
},
{
name: "same topics",
cachedTopics: map[string]int{"topic/a": 1, "topic/b": 2},
newTopics: map[string]int{"topic/a": 1, "topic/b": 2},
expected: false,
},
{
name: "same topics different min peer counts",
cachedTopics: map[string]int{"topic/a": 1, "topic/b": 2},
newTopics: map[string]int{"topic/a": 5, "topic/b": 10},
expected: false,
},
{
name: "new topic added",
cachedTopics: map[string]int{"topic/a": 1},
newTopics: map[string]int{"topic/a": 1, "topic/b": 2},
expected: true,
},
{
name: "topic removed",
cachedTopics: map[string]int{"topic/a": 1, "topic/b": 2},
newTopics: map[string]int{"topic/a": 1},
expected: true,
},
{
name: "same length different topics",
cachedTopics: map[string]int{"topic/a": 1, "topic/b": 2},
newTopics: map[string]int{"topic/a": 1, "topic/c": 2},
expected: true,
},
{
name: "from empty to non-empty",
cachedTopics: map[string]int{},
newTopics: map[string]int{"topic/a": 1},
expected: true,
},
{
name: "from non-empty to empty",
cachedTopics: map[string]int{"topic/a": 1},
newTopics: map[string]int{},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dialer := &GossipPeerDialer{
cachedTopics: tt.cachedTopics,
}
got := dialer.topicsChanged(tt.newTopics)
require.Equal(t, tt.expected, got)
})
}
}
type mockCrawler struct {
mu sync.Mutex
peers map[string][]*enode.Node
consume bool
}
func (m *mockCrawler) Start(gossipcrawler.TopicExtractor) error {
return nil
}
func (m *mockCrawler) Stop() {}
func (m *mockCrawler) RemovePeerByPeerId(peer.ID) {}
func (m *mockCrawler) RemoveTopic(string) {}
func (m *mockCrawler) TriggerCrawl() {}
func (m *mockCrawler) PeersForTopic(topic string) []*enode.Node {
m.mu.Lock()
defer m.mu.Unlock()
nodes := m.peers[topic]
if len(nodes) == 0 {
return nil
}
copied := slices.Clone(nodes)
if m.consume {
m.peers[topic] = nil
}
return copied
}
type mockDialer struct {
mu sync.Mutex
dials []*enode.Node
}
func (m *mockDialer) DialPeers(ctx context.Context, maxConcurrentDials int, nodes []*enode.Node) uint {
m.mu.Lock()
defer m.mu.Unlock()
m.dials = append(m.dials, nodes...)
return uint(len(nodes))
}
func (m *mockDialer) dialCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.dials)
}
func (m *mockDialer) dialedNodes() []*enode.Node {
m.mu.Lock()
defer m.mu.Unlock()
return slices.Clone(m.dials)
}
func newTestNode(t *testing.T, ip string, tcpPort uint16) *enode.Node {
priv, _, err := crypto.GenerateSecp256k1Key(rand.Reader)
require.NoError(t, err)
return newTestNodeWithPriv(t, priv, ip, tcpPort)
}
func newTestNodeWithPriv(t *testing.T, priv crypto.PrivKey, ip string, tcpPort uint16) *enode.Node {
t.Helper()
db, err := enode.OpenDB("")
require.NoError(t, err)
t.Cleanup(func() {
db.Close()
})
convertedKey, err := ecdsa.ConvertFromInterfacePrivKey(priv)
require.NoError(t, err)
localNode := enode.NewLocalNode(db, convertedKey)
localNode.SetStaticIP(net.ParseIP(ip))
localNode.Set(enr.TCP(tcpPort))
localNode.Set(enr.UDP(tcpPort))
return localNode.Node()
}

View File

@@ -0,0 +1,606 @@
package p2p
import (
"context"
"fmt"
"slices"
"sync"
"time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/gossipcrawler"
"github.com/pkg/errors"
"golang.org/x/sync/semaphore"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/ethereum/go-ethereum/p2p/enode"
)
const (
semaphoreWeight = int64(1)
// pingBufferSizeScaleFactor determines the ping channel buffer size as a multiple
// of maxConcurrentPings. The crawl loop blocks when the ping buffer is full, so we
// need headroom beyond the number of concurrent pings allowed. Since pings to
// unreachable peers may timeout (taking longer to complete), a larger buffer ensures
// the crawler can continue discovering peers without stalling while waiting for slow
// or failed pings to drain.
pingBufferSizeScaleFactor = 4
)
type peerNode struct {
isPinged bool
node *enode.Node
peerID peer.ID
topics map[string]struct{}
}
type crawledPeers struct {
mu sync.RWMutex
peerNodeByEnode map[enode.ID]*peerNode
peerNodeByPid map[peer.ID]*peerNode
peersByTopic map[string]map[*peerNode]struct{}
}
func (cp *crawledPeers) updateStatusToPinged(enodeID enode.ID) {
cp.mu.Lock()
defer cp.mu.Unlock()
existingPNode, ok := cp.peerNodeByEnode[enodeID]
if !ok {
return
}
// we only want to ping a node with a given NodeId once -> not on every sequence number change
// as ping is simply a test of a node being reachable and not fake
existingPNode.isPinged = true
}
func (cp *crawledPeers) updatePeer(node *enode.Node, topics []string) (bool, error) {
if node == nil {
return false, errors.New("node is nil")
}
cp.mu.Lock()
defer cp.mu.Unlock()
enodeID := node.ID()
existingPNode, ok := cp.peerNodeByEnode[enodeID]
if ok && existingPNode.node == nil {
return false, errors.New("enode is nil for enodeId")
}
// we don't want to update enodes with a lower sequence number as they're stale records
if ok && existingPNode.node.Seq() >= node.Seq() {
return false, nil
}
if !ok {
// this is a new peer
peerID, err := enodeToPeerID(node)
if err != nil {
return false, fmt.Errorf("converting enode to peer ID: %w", err)
}
existingPNode = &peerNode{
node: node,
peerID: peerID,
topics: make(map[string]struct{}),
}
cp.peerNodeByEnode[enodeID] = existingPNode
cp.peerNodeByPid[peerID] = existingPNode
} else {
existingPNode.node = node
}
cp.updateTopicsUnlocked(existingPNode, topics)
cp.recordMetricsUnlocked()
if existingPNode.isPinged || len(topics) == 0 {
return false, nil
}
return true, nil
}
func (cp *crawledPeers) removeTopic(topic string) {
cp.mu.Lock()
defer cp.mu.Unlock()
// Get all peers subscribed to this topic
peers, ok := cp.peersByTopic[topic]
if !ok {
return // Topic doesn't exist
}
// Remove the topic from each peer's topic list
for pnode := range peers {
delete(pnode.topics, topic)
// remove the peer if it has no more topics left
if len(pnode.topics) == 0 {
cp.updateTopicsUnlocked(pnode, nil)
}
}
// Remove the topic from byTopic map
delete(cp.peersByTopic, topic)
cp.recordMetricsUnlocked()
}
func (cp *crawledPeers) removePeerByPeerId(peerID peer.ID) {
cp.mu.Lock()
defer cp.mu.Unlock()
pnode, ok := cp.peerNodeByPid[peerID]
if !ok {
return
}
// Use updateTopicsUnlocked with empty topics to remove the peer
cp.updateTopicsUnlocked(pnode, nil)
cp.recordMetricsUnlocked()
}
func (cp *crawledPeers) removePeerByNodeId(enodeID enode.ID) {
cp.mu.Lock()
defer cp.mu.Unlock()
pnode, ok := cp.peerNodeByEnode[enodeID]
if !ok {
return
}
cp.updateTopicsUnlocked(pnode, nil)
cp.recordMetricsUnlocked()
}
func (cp *crawledPeers) recordMetricsUnlocked() {
gossipCrawlerPeersByEnodeCount.Set(float64(len(cp.peerNodeByEnode)))
gossipCrawlerPeersByPidCount.Set(float64(len(cp.peerNodeByPid)))
gossipCrawlerTopicsCount.Set(float64(len(cp.peersByTopic)))
}
func (cp *crawledPeers) logPeerCounts() {
cp.mu.RLock()
defer cp.mu.RUnlock()
for topic, peers := range cp.peersByTopic {
// Count only pinged peers (verified reachable)
pingedCount := 0
for pnode := range peers {
if pnode.isPinged {
pingedCount++
}
}
log.WithField("topic", topic).
WithField("totalPeers", len(peers)).
WithField("pingedPeers", pingedCount).
Debug("Crawler indexed peers for topic")
}
}
func (cp *crawledPeers) cleanupPeer(pnode *peerNode) {
delete(cp.peerNodeByPid, pnode.peerID)
delete(cp.peerNodeByEnode, pnode.node.ID())
for t := range pnode.topics {
if peers, ok := cp.peersByTopic[t]; ok {
delete(peers, pnode)
if len(peers) == 0 {
delete(cp.peersByTopic, t)
}
}
}
pnode.topics = nil // Clear topics to indicate removal.
}
func (cp *crawledPeers) removeOldTopicsFromPeer(pnode *peerNode, newTopics map[string]struct{}) {
for oldTopic := range pnode.topics {
if _, ok := newTopics[oldTopic]; !ok {
if peers, ok := cp.peersByTopic[oldTopic]; ok {
delete(peers, pnode)
if len(peers) == 0 {
delete(cp.peersByTopic, oldTopic)
}
}
}
}
}
func (cp *crawledPeers) addNewTopicsToPeer(pnode *peerNode, newTopics map[string]struct{}) {
for newTopic := range newTopics {
if _, ok := pnode.topics[newTopic]; !ok {
if _, ok := cp.peersByTopic[newTopic]; !ok {
cp.peersByTopic[newTopic] = make(map[*peerNode]struct{})
}
cp.peersByTopic[newTopic][pnode] = struct{}{}
}
}
}
// updateTopicsUnlocked updates the topics associated with a peer node.
// If the topics slice is empty, the peer is completely removed from the crawled peers.
// Otherwise, it updates the peer's topics by removing old topics that are no longer
// present and adding new topics. This method assumes the caller holds the lock on cp.mu.
// If a topic has no peers after this update, it is removed from the list of topics we track peers for.
func (cp *crawledPeers) updateTopicsUnlocked(pnode *peerNode, topics []string) {
// If topics is empty, remove the peer completely.
if len(topics) == 0 {
cp.cleanupPeer(pnode)
return
}
newTopics := make(map[string]struct{})
for _, t := range topics {
newTopics[t] = struct{}{}
}
// Remove old topics that are no longer present.
cp.removeOldTopicsFromPeer(pnode, newTopics)
// Add new topics.
cp.addNewTopicsToPeer(pnode, newTopics)
pnode.topics = newTopics
}
func (cp *crawledPeers) peersForTopic(topic string, filter gossipcrawler.PeerFilterFunc) []peerNode {
cp.mu.RLock()
defer cp.mu.RUnlock()
peers, ok := cp.peersByTopic[topic]
if !ok {
return nil
}
var peerNodes []peerNode
for pnode := range peers {
if pnode.node == nil {
continue
}
if pnode.isPinged && filter(pnode.node) {
peerNodes = append(peerNodes, *pnode)
}
}
return peerNodes
}
// GossipPeerCrawler discovers and maintains a registry of peers subscribed to gossipsub topics.
// It uses discv5 to find peers, extracts their topic subscriptions from ENR records, and verifies
// their reachability via ping. Only peers that have been successfully pinged are returned when
// querying for peers on a given topic. The crawler runs three background loops: one for discovery,
// one for ping verification, and one for periodic cleanup of stale or filtered-out peers.
type GossipPeerCrawler struct {
ctx context.Context
crawlInterval, crawlTimeout time.Duration
crawledPeers *crawledPeers
// Discovery interface for finding peers
dv5 ListenerRebooter
p2pSvc *Service
topicExtractor gossipcrawler.TopicExtractor
peerFilter gossipcrawler.PeerFilterFunc
scorer PeerScoreFunc
pingCh chan enode.Node
pingSemaphore *semaphore.Weighted
triggerCrawlCh chan struct{}
once sync.Once
}
// cleanupInterval controls how frequently we sweep crawled peers and prune
// those that are no longer useful.
const cleanupInterval = 5 * time.Minute
// crawlerLogInterval controls how frequently we log peer counts per topic.
const crawlerLogInterval = 10 * time.Minute
// PeerScoreFunc calculates a reputation score for a given peer ID.
// Higher scores indicate more desirable peers. This function is used by PeersForTopic
// to sort returned peers in descending order of quality, allowing callers to prioritize
// connections to the most reliable peers.
type PeerScoreFunc func(peer.ID) float64
// NewGossipPeerCrawler creates a new crawler for discovering gossipsub peers.
// The crawler uses the provided discv5 listener to discover peers and tracks their
// topic subscriptions. Parameters:
// - p2pSvc: The P2P service for network operations
// - dv5: The discv5 listener used for peer discovery and ping verification
// - crawlTimeout: Maximum duration for each crawl iteration
// - crawlInterval: The duration between each crawl iteration
// - maxConcurrentPings: Limits parallel ping operations to avoid overwhelming the network
// - peerFilter: Determines which discovered peers should be tracked
// - scorer: Calculates peer quality scores for sorting results
//
// Returns an error if any required parameter is nil or invalid.
func NewGossipPeerCrawler(
ctx context.Context,
p2pSvc *Service,
dv5 ListenerRebooter,
crawlTimeout time.Duration,
crawlInterval time.Duration,
maxConcurrentPings int64,
peerFilter gossipcrawler.PeerFilterFunc,
scorer PeerScoreFunc,
) (*GossipPeerCrawler, error) {
if p2pSvc == nil {
return nil, errors.New("p2pSvc is nil")
}
if dv5 == nil {
return nil, errors.New("dv5 is nil")
}
if crawlTimeout <= 0 {
return nil, errors.New("crawl timeout must be greater than 0")
}
if crawlInterval <= 0 {
return nil, errors.New("crawl interval must be greater than 0")
}
if maxConcurrentPings <= 0 {
return nil, errors.New("max concurrent pings must be greater than 0")
}
if peerFilter == nil {
return nil, errors.New("peer filter is nil")
}
if scorer == nil {
return nil, errors.New("peer scorer is nil")
}
g := &GossipPeerCrawler{
ctx: ctx,
crawlInterval: crawlInterval,
crawlTimeout: crawlTimeout,
p2pSvc: p2pSvc,
dv5: dv5,
peerFilter: peerFilter,
scorer: scorer,
}
g.pingCh = make(chan enode.Node, pingBufferSizeScaleFactor*maxConcurrentPings)
g.pingSemaphore = semaphore.NewWeighted(maxConcurrentPings)
g.triggerCrawlCh = make(chan struct{}, 1)
g.crawledPeers = &crawledPeers{
peerNodeByEnode: make(map[enode.ID]*peerNode),
peerNodeByPid: make(map[peer.ID]*peerNode),
peersByTopic: make(map[string]map[*peerNode]struct{}),
}
return g, nil
}
// PeersForTopic returns a list of enode records for peers subscribed to the given topic.
// Only peers that have been successfully pinged (verified as reachable) and pass the
// configured peer filter are included. Results are sorted in descending order by peer
// score, so higher-quality peers appear first. Returns nil if no peers are found for
// the topic. The returned slice should not be modified as it contains pointers to
// internal enode records.
func (g *GossipPeerCrawler) PeersForTopic(topic string) []*enode.Node {
peerNodes := g.crawledPeers.peersForTopic(topic, g.peerFilter)
slices.SortFunc(peerNodes, func(a, b peerNode) int {
scoreA := g.scorer(a.peerID)
scoreB := g.scorer(b.peerID)
if scoreA > scoreB {
return -1
}
if scoreA < scoreB {
return 1
}
return 0
})
nodes := make([]*enode.Node, 0, len(peerNodes))
for _, pn := range peerNodes {
nodes = append(nodes, pn.node)
}
return nodes
}
// RemovePeerByPeerId removes a peer from the crawler's registry by their libp2p peer ID.
// This also removes the peer from all topic subscriptions they were associated with.
// If the peer is not found, this operation is a no-op.
func (g *GossipPeerCrawler) RemovePeerByPeerId(peerID peer.ID) {
g.crawledPeers.removePeerByPeerId(peerID)
}
// RemoveTopic removes a topic and all its peer associations from the crawler.
// Peers that were only subscribed to this topic are completely removed from the registry.
// Peers subscribed to other topics remain tracked for those topics.
// If the topic does not exist, this operation is a no-op.
func (g *GossipPeerCrawler) RemoveTopic(topic string) {
g.crawledPeers.removeTopic(topic)
}
// Start begins the crawler's background operations. It launches three goroutines:
// a crawl loop that periodically discovers new peers via discv5, a ping loop that
// verifies peer reachability, and a cleanup loop that removes stale or filtered peers.
// The provided TopicExtractor is used to determine which gossipsub topics each
// discovered peer subscribes to. Start is idempotent; subsequent calls after the
// first are no-ops. Returns an error if the topic extractor is nil.
func (g *GossipPeerCrawler) Start(te gossipcrawler.TopicExtractor) error {
if te == nil {
return errors.New("topic extractor is nil")
}
g.once.Do(func() {
g.topicExtractor = te
go g.crawlLoop()
go g.pingLoop()
go g.cleanupLoop()
go g.logPeerCountsLoop()
})
return nil
}
func (g *GossipPeerCrawler) pingLoop() {
for {
select {
case node := <-g.pingCh:
if err := g.pingSemaphore.Acquire(g.ctx, semaphoreWeight); err != nil {
return
}
go func(node *enode.Node) {
defer g.pingSemaphore.Release(semaphoreWeight)
if err := g.dv5.Ping(node); err != nil {
log.WithError(err).WithField("node", node.ID()).Debug("Failed to ping node")
g.crawledPeers.removePeerByNodeId(node.ID())
return
}
g.crawledPeers.updateStatusToPinged(node.ID())
}(&node)
case <-g.ctx.Done():
return
}
}
}
func (g *GossipPeerCrawler) crawlLoop() {
for {
g.crawl()
select {
case <-time.After(g.crawlInterval):
case <-g.triggerCrawlCh:
case <-g.ctx.Done():
return
}
}
}
func (g *GossipPeerCrawler) crawl() {
ctx, cancel := context.WithTimeout(g.ctx, g.crawlTimeout)
defer cancel()
iterator := g.dv5.RandomNodes()
// Ensure iterator unblocks on context cancellation or timeout
go func() {
<-ctx.Done()
iterator.Close()
}()
for iterator.Next() {
if ctx.Err() != nil {
return
}
node := iterator.Node()
if node == nil {
continue
}
if !g.peerFilter(node) {
g.crawledPeers.removePeerByNodeId(node.ID())
continue
}
topics, err := g.topicExtractor(ctx, node)
if err != nil {
log.WithError(err).WithField("node", node.ID()).Debug("Failed to extract topics, skipping")
continue
}
shouldPing, err := g.crawledPeers.updatePeer(node, topics)
if err != nil {
log.WithError(err).WithField("node", node.ID()).Error("Failed to update crawled peers")
}
if !shouldPing {
continue
}
select {
case g.pingCh <- *node:
case <-g.ctx.Done():
return
}
}
}
// cleanupLoop periodically removes peers that the filter rejects or that
// have no topics of interest. It uses the same context lifecycle as other
// background loops.
func (g *GossipPeerCrawler) cleanupLoop() {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
// Initial cleanup to catch any leftovers from startup state
g.cleanup()
for {
select {
case <-ticker.C:
g.cleanup()
case <-g.ctx.Done():
return
}
}
}
// cleanup scans the crawled peer set and removes entries that either fail
// the current peer filter or have no topics of interest remaining.
func (g *GossipPeerCrawler) cleanup() {
cp := g.crawledPeers
// Snapshot current peers to evaluate without holding the lock during
// filter and topic extraction.
cp.mu.RLock()
peers := make([]*peerNode, 0, len(cp.peerNodeByPid))
for _, p := range cp.peerNodeByPid {
peers = append(peers, p)
}
cp.mu.RUnlock()
for _, p := range peers {
// Remove peers that no longer pass the filter
if !g.peerFilter(p.node) {
cp.removePeerByNodeId(p.node.ID())
continue
}
// Re-extract topics; if the extractor errors or yields none, drop the peer.
topics, err := g.topicExtractor(g.ctx, p.node)
if err != nil || len(topics) == 0 {
cp.removePeerByNodeId(p.node.ID())
}
}
}
func (g *GossipPeerCrawler) logPeerCountsLoop() {
ticker := time.NewTicker(crawlerLogInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
g.crawledPeers.logPeerCounts()
case <-g.ctx.Done():
return
}
}
}
// TriggerCrawl requests an immediate crawl. If a crawl trigger is already
// pending, this call is ignored (non-blocking). This allows external systems
// to request a crawl without waiting for the regular interval.
func (g *GossipPeerCrawler) TriggerCrawl() {
select {
case g.triggerCrawlCh <- struct{}{}:
log.Info("Triggering crawl")
default:
// Channel full, crawl already triggered
}
}
// enodeToPeerID converts an enode record to a peer ID.
func enodeToPeerID(n *enode.Node) (peer.ID, error) {
info, _, err := convertToAddrInfo(n)
if err != nil {
return "", fmt.Errorf("converting enode to addr info: %w", err)
}
if info == nil {
return "", errors.New("peer info is nil")
}
return info.ID, nil
}

View File

@@ -0,0 +1,787 @@
package p2p
import (
"context"
"net"
"testing"
"time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/gossipcrawler"
p2ptest "github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/testing"
"github.com/ethereum/go-ethereum/p2p/enode"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/require"
require2 "github.com/stretchr/testify/require"
)
// Helpers for crawledPeers tests
func newTestCrawledPeers() *crawledPeers {
return &crawledPeers{
peerNodeByEnode: make(map[enode.ID]*peerNode),
peerNodeByPid: make(map[peer.ID]*peerNode),
peersByTopic: make(map[string]map[*peerNode]struct{}),
}
}
func addPeerWithTopics(t *testing.T, cp *crawledPeers, node *enode.Node, topics []string, pinged bool) *peerNode {
t.Helper()
pid, err := enodeToPeerID(node)
require.NoError(t, err)
p := &peerNode{
isPinged: pinged,
node: node,
peerID: pid,
topics: make(map[string]struct{}),
}
cp.mu.Lock()
cp.peerNodeByEnode[p.node.ID()] = p
cp.peerNodeByPid[p.peerID] = p
cp.updateTopicsUnlocked(p, topics)
cp.mu.Unlock()
return p
}
func TestUpdateStatusToPinged(t *testing.T) {
localNode := createTestNodeRandom(t)
node1 := localNode.Node()
localNode2 := createTestNodeRandom(t)
node2 := localNode2.Node()
cases := []struct {
name string
prep func(*crawledPeers)
target *enode.Node
expectPinged map[enode.ID]bool
}{
{
name: "sets pinged for existing peer",
prep: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, node1, []string{"a"}, false)
},
target: node1,
expectPinged: map[enode.ID]bool{
node1.ID(): true,
},
},
{
name: "idempotent when already pinged",
prep: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, node1, []string{"a"}, true)
},
target: node1,
expectPinged: map[enode.ID]bool{
node1.ID(): true,
},
},
{
name: "no change when peer missing",
prep: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, node1, []string{"a"}, false)
},
target: node2,
expectPinged: map[enode.ID]bool{
node1.ID(): false,
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cp := newTestCrawledPeers()
tc.prep(cp)
cp.updateStatusToPinged(tc.target.ID())
cp.mu.RLock()
defer cp.mu.RUnlock()
for id, exp := range tc.expectPinged {
if p := cp.peerNodeByEnode[id]; p != nil {
require.Equal(t, exp, p.isPinged)
}
}
})
}
}
func TestRemoveTopic(t *testing.T) {
localNode := createTestNodeRandom(t)
node1 := localNode.Node()
localNode2 := createTestNodeRandom(t)
node2 := localNode2.Node()
topic1 := "t1"
topic2 := "t2"
cases := []struct {
name string
prep func(*crawledPeers)
topic string
check func(*testing.T, *crawledPeers)
}{
{
name: "removes topic from all peers and index",
prep: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, node1, []string{"t1", "t2"}, true)
addPeerWithTopics(t, cp, node2, []string{"t1"}, true)
},
topic: topic1,
check: func(t *testing.T, cp *crawledPeers) {
_, ok := cp.peersByTopic[topic1]
require.False(t, ok)
for _, p := range cp.peerNodeByPid {
_, has := p.topics[topic1]
require.False(t, has)
}
// Ensure other topics remain
_, ok = cp.peersByTopic[topic2]
require.True(t, ok)
},
},
{
name: "no-op when topic missing",
prep: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, node1, []string{"t2"}, true)
},
topic: topic1,
check: func(t *testing.T, cp *crawledPeers) {
_, ok := cp.peersByTopic[topic2]
require.True(t, ok)
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cp := newTestCrawledPeers()
tc.prep(cp)
cp.removeTopic(tc.topic)
tc.check(t, cp)
})
}
}
func TestRemovePeer(t *testing.T) {
localNode := createTestNodeRandom(t)
node1 := localNode.Node()
localNode2 := createTestNodeRandom(t)
node2 := localNode2.Node()
cases := []struct {
name string
prep func(*crawledPeers)
target enode.ID
wantTopics int
}{
{
name: "removes existing peer and prunes empty topic",
prep: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, node1, []string{"t1"}, true)
},
target: node1.ID(),
wantTopics: 0,
},
{
name: "removes only targeted peer; keeps topic for other",
prep: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, node1, []string{"t1"}, true)
addPeerWithTopics(t, cp, node2, []string{"t1"}, true)
},
target: node1.ID(),
wantTopics: 1, // byTopic should still have t1 with one peer
},
{
name: "no-op when peer missing",
prep: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, node1, []string{"t1"}, true)
},
target: node2.ID(),
wantTopics: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cp := newTestCrawledPeers()
tc.prep(cp)
cp.removePeerByNodeId(tc.target)
cp.mu.RLock()
defer cp.mu.RUnlock()
require.Len(t, cp.peersByTopic, tc.wantTopics)
})
}
}
func TestRemovePeerId(t *testing.T) {
localNode := createTestNodeRandom(t)
node1 := localNode.Node()
localNode2 := createTestNodeRandom(t)
node2 := localNode2.Node()
pid1, err := enodeToPeerID(node1)
require.NoError(t, err)
pid2, err := enodeToPeerID(node2)
require.NoError(t, err)
cases := []struct {
name string
prep func(*crawledPeers)
target peer.ID
wantTopics int
wantPeers int
}{
{
name: "removes existing peer by id and prunes topic",
prep: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, node1, []string{"t1"}, true)
},
target: pid1,
wantTopics: 0,
wantPeers: 0,
},
{
name: "removes only targeted peer id; keeps topic for other",
prep: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, node1, []string{"t1"}, true)
addPeerWithTopics(t, cp, node2, []string{"t1"}, true)
},
target: pid1,
wantTopics: 1,
wantPeers: 1,
},
{
name: "no-op when peer id missing",
prep: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, node1, []string{"t1"}, true)
},
target: pid2,
wantTopics: 1,
wantPeers: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cp := newTestCrawledPeers()
tc.prep(cp)
cp.removePeerByPeerId(tc.target)
cp.mu.RLock()
defer cp.mu.RUnlock()
require.Len(t, cp.peersByTopic, tc.wantTopics)
require.Len(t, cp.peerNodeByPid, tc.wantPeers)
})
}
}
func TestUpdateCrawledIfNewer(t *testing.T) {
newCrawler := func() (*crawledPeers, *GossipPeerCrawler, func()) {
ctx, cancel := context.WithCancel(context.Background())
g := &GossipPeerCrawler{
ctx: ctx,
pingCh: make(chan enode.Node, 8),
}
cp := newTestCrawledPeers()
return cp, g, cancel
}
// Helper: local node that will cause enodeToPeerID to fail (no TCP/UDP multiaddrs)
newNodeNoPorts := func(t *testing.T) *enode.Node {
_, privKey := createAddrAndPrivKey(t)
db, err := enode.OpenDB("")
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
ln := enode.NewLocalNode(db, privKey)
// Do not set TCP/UDP; keep only IP
ln.SetStaticIP(net.ParseIP("127.0.0.1"))
return ln.Node()
}
// Ensure both A nodes have the same enode.ID but differing seq
ln := createTestNodeRandom(t)
nodeA1 := ln.Node()
setNodeSeq(ln, nodeA1.Seq()+1)
nodeA2 := ln.Node()
tests := []struct {
name string
arrange func(*crawledPeers)
invokeNode *enode.Node
invokeTopics []string
expectedShouldPing bool
expectErr bool
assert func(*testing.T, *crawledPeers, <-chan enode.Node)
}{
{
name: "new peer with topics adds peer and pings",
arrange: func(cp *crawledPeers) {},
invokeNode: nodeA1,
invokeTopics: []string{"a"},
expectedShouldPing: true,
assert: func(t *testing.T, cp *crawledPeers, ch <-chan enode.Node) {
cp.mu.RLock()
require.Len(t, cp.peerNodeByEnode, 1)
require.Len(t, cp.peerNodeByPid, 1)
require.Contains(t, cp.peersByTopic, "a")
cp.mu.RUnlock()
},
},
{
name: "new peer with empty topics is removed",
arrange: func(cp *crawledPeers) {},
invokeNode: nodeA1,
invokeTopics: nil,
assert: func(t *testing.T, cp *crawledPeers, ch <-chan enode.Node) {
cp.mu.RLock()
require.Empty(t, cp.peerNodeByEnode)
require.Empty(t, cp.peerNodeByPid)
require.Empty(t, cp.peersByTopic)
cp.mu.RUnlock()
},
},
{
name: "existing peer lower seq is ignored",
arrange: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, nodeA2, []string{"x"}, false) // higher seq exists
},
invokeNode: nodeA1, // lower seq
invokeTopics: []string{"a", "b"},
assert: func(t *testing.T, cp *crawledPeers, ch <-chan enode.Node) {
cp.mu.RLock()
require.Contains(t, cp.peersByTopic, "x")
require.NotContains(t, cp.peersByTopic, "a")
cp.mu.RUnlock()
},
},
{
name: "existing peer equal seq is ignored",
arrange: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, nodeA1, []string{"x"}, false)
},
invokeNode: nodeA1,
invokeTopics: []string{"a"},
assert: func(t *testing.T, cp *crawledPeers, ch <-chan enode.Node) {
cp.mu.RLock()
require.Contains(t, cp.peersByTopic, "x")
require.NotContains(t, cp.peersByTopic, "a")
cp.mu.RUnlock()
},
},
{
name: "existing peer higher seq updates topics and pings",
arrange: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, nodeA1, []string{"x"}, false)
},
invokeNode: nodeA2,
invokeTopics: []string{"a"},
expectedShouldPing: true,
assert: func(t *testing.T, cp *crawledPeers, ch <-chan enode.Node) {
cp.mu.RLock()
require.NotContains(t, cp.peersByTopic, "x")
require.Contains(t, cp.peersByTopic, "a")
cp.mu.RUnlock()
},
},
{
name: "existing peer higher seq but empty topics removes peer",
arrange: func(cp *crawledPeers) {
addPeerWithTopics(t, cp, nodeA1, []string{"x"}, false)
},
invokeNode: nodeA2,
invokeTopics: nil,
assert: func(t *testing.T, cp *crawledPeers, ch <-chan enode.Node) {
cp.mu.RLock()
require.Empty(t, cp.peerNodeByEnode)
require.Empty(t, cp.peerNodeByPid)
cp.mu.RUnlock()
},
},
{
name: "corrupted existing entry with nil node is ignored",
arrange: func(cp *crawledPeers) {
pid, _ := enodeToPeerID(nodeA1)
cp.mu.Lock()
pn := &peerNode{node: nil, peerID: pid, topics: map[string]struct{}{"x": {}}}
cp.peerNodeByEnode[nodeA1.ID()] = pn
cp.peerNodeByPid[pid] = pn
cp.peersByTopic["x"] = map[*peerNode]struct{}{pn: {}}
cp.mu.Unlock()
},
expectErr: true,
invokeNode: nodeA2,
invokeTopics: []string{"a"},
assert: func(t *testing.T, cp *crawledPeers, ch <-chan enode.Node) {
cp.mu.RLock()
require.Contains(t, cp.peersByTopic, "x")
cp.mu.RUnlock()
},
},
{
name: "new peer with no ports causes enodeToPeerID error; no add",
arrange: func(cp *crawledPeers) {},
invokeNode: newNodeNoPorts(t),
invokeTopics: []string{"a"},
expectErr: true,
assert: func(t *testing.T, cp *crawledPeers, ch <-chan enode.Node) {
cp.mu.RLock()
require.Empty(t, cp.peerNodeByEnode)
require.Empty(t, cp.peerNodeByPid)
require.Empty(t, cp.peersByTopic)
cp.mu.RUnlock()
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cp, g, cancel := newCrawler()
defer cancel()
tc.arrange(cp)
shouldPing, err := cp.updatePeer(tc.invokeNode, tc.invokeTopics)
if tc.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Equal(t, shouldPing, tc.expectedShouldPing)
tc.assert(t, cp, g.pingCh)
})
}
}
func TestPeersForTopic(t *testing.T) {
t.Parallel()
newCrawler := func(filter gossipcrawler.PeerFilterFunc) (*GossipPeerCrawler, *crawledPeers) {
g := &GossipPeerCrawler{
peerFilter: filter,
scorer: func(peer.ID) float64 { return 0 },
crawledPeers: newTestCrawledPeers(),
}
return g, g.crawledPeers
}
// Prepare nodes
ln1 := createTestNodeRandom(t)
ln2 := createTestNodeRandom(t)
ln3 := createTestNodeRandom(t)
n1, n2, n3 := ln1.Node(), ln2.Node(), ln3.Node()
topic := "top"
cases := []struct {
name string
filter gossipcrawler.PeerFilterFunc
setup func(t *testing.T, g *GossipPeerCrawler, cp *crawledPeers)
wantIDs []enode.ID
}{
{
name: "no peers for topic returns empty",
filter: func(*enode.Node) bool { return true },
setup: func(t *testing.T, g *GossipPeerCrawler, cp *crawledPeers) {},
wantIDs: nil,
},
{
name: "excludes unpinged peers",
filter: func(*enode.Node) bool { return true },
setup: func(t *testing.T, g *GossipPeerCrawler, cp *crawledPeers) {
// Add one pinged and one not pinged on same topic
addPeerWithTopics(t, cp, n1, []string{string(topic)}, true)
addPeerWithTopics(t, cp, n2, []string{string(topic)}, false)
},
wantIDs: []enode.ID{n1.ID()},
},
{
name: "applies peer filter to exclude",
filter: func(n *enode.Node) bool { return n.ID() != n2.ID() },
setup: func(t *testing.T, g *GossipPeerCrawler, cp *crawledPeers) {
addPeerWithTopics(t, cp, n1, []string{string(topic)}, true)
addPeerWithTopics(t, cp, n2, []string{string(topic)}, true)
},
wantIDs: []enode.ID{n1.ID()},
},
{
name: "ignores peerNode with nil node",
filter: func(*enode.Node) bool { return true },
setup: func(t *testing.T, g *GossipPeerCrawler, cp *crawledPeers) {
addPeerWithTopics(t, cp, n1, []string{string(topic)}, true)
// Add n2 then set its node to nil to simulate corrupted entry
p2 := addPeerWithTopics(t, cp, n2, []string{string(topic)}, true)
cp.mu.Lock()
p2.node = nil
cp.mu.Unlock()
},
wantIDs: []enode.ID{n1.ID()},
},
{
name: "sorted by score descending",
filter: func(*enode.Node) bool { return true },
setup: func(t *testing.T, g *GossipPeerCrawler, cp *crawledPeers) {
// Add three pinged peers
p1 := addPeerWithTopics(t, cp, n1, []string{string(topic)}, true)
p2 := addPeerWithTopics(t, cp, n2, []string{string(topic)}, true)
p3 := addPeerWithTopics(t, cp, n3, []string{string(topic)}, true)
// Provide a deterministic scoring function
scores := map[peer.ID]float64{
p1.peerID: 3.0,
p2.peerID: 2.0,
p3.peerID: 1.0,
}
g.scorer = func(id peer.ID) float64 { return scores[id] }
},
wantIDs: []enode.ID{n1.ID(), n2.ID(), n3.ID()},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
g, cp := newCrawler(tc.filter)
tc.setup(t, g, cp)
got := g.PeersForTopic(topic)
var gotIDs []enode.ID
for _, n := range got {
gotIDs = append(gotIDs, n.ID())
}
if tc.wantIDs == nil {
require.Empty(t, gotIDs)
return
}
require.Equal(t, tc.wantIDs, gotIDs)
})
}
}
func TestCrawler_AddsAndPingsPeer(t *testing.T) {
// Create a test node with valid ENR entries (IP/TCP/UDP)
localNode := createTestNodeRandom(t)
node := localNode.Node()
// Prepare a mock iterator returning our single node
iterator := p2ptest.NewMockIterator([]*enode.Node{node})
// Prepare a mock listener with successful Ping
mockListener := p2ptest.NewMockListener(localNode, iterator)
mockListener.PingFunc = func(*enode.Node) error { return nil }
// Inject a permissive peer filter
filter := gossipcrawler.PeerFilterFunc(func(n *enode.Node) bool { return true })
// Create crawler with small intervals
scorer := func(peer.ID) float64 { return 0 }
g, err := NewGossipPeerCrawler(t.Context(), &Service{}, mockListener, 2*time.Second, 10*time.Millisecond, 4, filter, scorer)
require.NoError(t, err)
// Assign a simple topic extractor
topic := "test/topic"
topicExtractor := func(ctx context.Context, n *enode.Node) ([]string, error) {
return []string{topic}, nil
}
// Run ping loop in background and perform a single crawl
require.NoError(t, g.Start(topicExtractor))
// Verify that the peer has been indexed under the topic and marked as pinged
require2.Eventually(t, func() bool {
g.crawledPeers.mu.RLock()
defer g.crawledPeers.mu.RUnlock()
peers := g.crawledPeers.peersByTopic[topic]
if len(peers) == 0 {
return false
}
// Fetch the single peerNode and check status
for pn := range peers {
if pn == nil {
return false
}
return pn.isPinged
}
return false
}, 2*time.Second, 10*time.Millisecond)
}
func TestCrawler_SkipsPeer_WhenFilterRejects(t *testing.T) {
t.Parallel()
localNode := createTestNodeRandom(t)
node := localNode.Node()
iterator := p2ptest.NewMockIterator([]*enode.Node{node})
mockListener := p2ptest.NewMockListener(localNode, iterator)
mockListener.PingFunc = func(*enode.Node) error { return nil }
// Reject all peers via injected filter
filter := gossipcrawler.PeerFilterFunc(func(n *enode.Node) bool { return false })
scorer := func(peer.ID) float64 { return 0 }
g, err := NewGossipPeerCrawler(t.Context(), &Service{}, mockListener, 2*time.Second, 10*time.Millisecond, 2, filter, scorer)
if err != nil {
t.Fatalf("NewGossipPeerCrawler error: %v", err)
}
topic := "test/topic"
g.topicExtractor = func(ctx context.Context, n *enode.Node) ([]string, error) { return []string{topic}, nil }
g.crawl()
// Verify no peers are indexed, because filter rejected the node
g.crawledPeers.mu.RLock()
defer g.crawledPeers.mu.RUnlock()
if len(g.crawledPeers.peerNodeByEnode) != 0 || len(g.crawledPeers.peerNodeByPid) != 0 || len(g.crawledPeers.peersByTopic) != 0 {
t.Fatalf("expected no peers indexed, got byEnode=%d byPeerId=%d byTopic=%d",
len(g.crawledPeers.peerNodeByEnode), len(g.crawledPeers.peerNodeByPid), len(g.crawledPeers.peersByTopic))
}
}
func TestCrawler_RemoveTopic_RemovesTopicFromIndexes(t *testing.T) {
t.Parallel()
localNode := createTestNodeRandom(t)
node := localNode.Node()
iterator := p2ptest.NewMockIterator([]*enode.Node{node})
mockListener := p2ptest.NewMockListener(localNode, iterator)
mockListener.PingFunc = func(*enode.Node) error { return nil }
filter := gossipcrawler.PeerFilterFunc(func(n *enode.Node) bool { return true })
scorer := func(peer.ID) float64 { return 0 }
g, err := NewGossipPeerCrawler(t.Context(), &Service{}, mockListener, 2*time.Second, 10*time.Millisecond, 2, filter, scorer)
if err != nil {
t.Fatalf("NewGossipPeerCrawler error: %v", err)
}
topic1 := "test/topic1"
topic2 := "test/topic2"
g.topicExtractor = func(ctx context.Context, n *enode.Node) ([]string, error) { return []string{topic1, topic2}, nil }
// Single crawl to index topics
g.crawl()
// Remove one topic and assert it is pruned from all indexes
g.RemoveTopic(topic1)
g.crawledPeers.mu.RLock()
defer g.crawledPeers.mu.RUnlock()
if _, ok := g.crawledPeers.peersByTopic[topic1]; ok {
t.Fatalf("expected topic1 to be removed from byTopic")
}
// Ensure peer still exists and retains topic2
for _, pn := range g.crawledPeers.peerNodeByEnode {
if _, has1 := pn.topics[topic1]; has1 {
t.Fatalf("expected topic1 to be removed from peer topics")
}
if _, has2 := pn.topics[topic2]; !has2 {
t.Fatalf("expected topic2 to remain for peer")
}
}
}
func TestCrawledPeersMetrics(t *testing.T) {
localNode1 := createTestNodeRandom(t)
node1 := localNode1.Node()
localNode2 := createTestNodeRandom(t)
node2 := localNode2.Node()
pid1, err := enodeToPeerID(node1)
require.NoError(t, err)
t.Run("updatePeer records metrics", func(t *testing.T) {
cp := newTestCrawledPeers()
// Add first peer with two topics
_, err := cp.updatePeer(node1, []string{"topic1", "topic2"})
require.NoError(t, err)
require.Equal(t, float64(1), testutil.ToFloat64(gossipCrawlerPeersByEnodeCount))
require.Equal(t, float64(1), testutil.ToFloat64(gossipCrawlerPeersByPidCount))
require.Equal(t, float64(2), testutil.ToFloat64(gossipCrawlerTopicsCount))
// Add second peer with one overlapping topic
_, err = cp.updatePeer(node2, []string{"topic1", "topic3"})
require.NoError(t, err)
require.Equal(t, float64(2), testutil.ToFloat64(gossipCrawlerPeersByEnodeCount))
require.Equal(t, float64(2), testutil.ToFloat64(gossipCrawlerPeersByPidCount))
require.Equal(t, float64(3), testutil.ToFloat64(gossipCrawlerTopicsCount))
})
t.Run("removePeerByPeerId records metrics", func(t *testing.T) {
cp := newTestCrawledPeers()
// Add two peers
_, err := cp.updatePeer(node1, []string{"topic1"})
require.NoError(t, err)
_, err = cp.updatePeer(node2, []string{"topic1", "topic2"})
require.NoError(t, err)
require.Equal(t, float64(2), testutil.ToFloat64(gossipCrawlerPeersByEnodeCount))
require.Equal(t, float64(2), testutil.ToFloat64(gossipCrawlerTopicsCount))
// Remove first peer by peer ID
cp.removePeerByPeerId(pid1)
require.Equal(t, float64(1), testutil.ToFloat64(gossipCrawlerPeersByEnodeCount))
require.Equal(t, float64(1), testutil.ToFloat64(gossipCrawlerPeersByPidCount))
require.Equal(t, float64(2), testutil.ToFloat64(gossipCrawlerTopicsCount))
})
t.Run("removePeerByNodeId records metrics", func(t *testing.T) {
cp := newTestCrawledPeers()
// Add two peers
_, err := cp.updatePeer(node1, []string{"topic1"})
require.NoError(t, err)
_, err = cp.updatePeer(node2, []string{"topic2"})
require.NoError(t, err)
require.Equal(t, float64(2), testutil.ToFloat64(gossipCrawlerPeersByEnodeCount))
require.Equal(t, float64(2), testutil.ToFloat64(gossipCrawlerTopicsCount))
// Remove first peer by enode ID
cp.removePeerByNodeId(node1.ID())
require.Equal(t, float64(1), testutil.ToFloat64(gossipCrawlerPeersByEnodeCount))
require.Equal(t, float64(1), testutil.ToFloat64(gossipCrawlerPeersByPidCount))
require.Equal(t, float64(1), testutil.ToFloat64(gossipCrawlerTopicsCount))
})
t.Run("removeTopic records metrics", func(t *testing.T) {
cp := newTestCrawledPeers()
// Add two peers with overlapping topics
_, err := cp.updatePeer(node1, []string{"topic1", "topic2"})
require.NoError(t, err)
_, err = cp.updatePeer(node2, []string{"topic1"})
require.NoError(t, err)
require.Equal(t, float64(2), testutil.ToFloat64(gossipCrawlerPeersByEnodeCount))
require.Equal(t, float64(2), testutil.ToFloat64(gossipCrawlerTopicsCount))
// Remove topic1 - this should also remove node2 which only had topic1
cp.removeTopic("topic1")
require.Equal(t, float64(1), testutil.ToFloat64(gossipCrawlerPeersByEnodeCount))
require.Equal(t, float64(1), testutil.ToFloat64(gossipCrawlerPeersByPidCount))
require.Equal(t, float64(1), testutil.ToFloat64(gossipCrawlerTopicsCount))
})
t.Run("updatePeer with empty topics removes peer and records metrics", func(t *testing.T) {
cp := newTestCrawledPeers()
// Add peer with topics
_, err := cp.updatePeer(node1, []string{"topic1"})
require.NoError(t, err)
require.Equal(t, float64(1), testutil.ToFloat64(gossipCrawlerPeersByEnodeCount))
require.Equal(t, float64(1), testutil.ToFloat64(gossipCrawlerTopicsCount))
// Increment sequence number to ensure update is processed
setNodeSeq(localNode1, node1.Seq()+1)
node1Updated := localNode1.Node()
// Update with empty topics - should remove the peer
_, err = cp.updatePeer(node1Updated, nil)
require.NoError(t, err)
require.Equal(t, float64(0), testutil.ToFloat64(gossipCrawlerPeersByEnodeCount))
require.Equal(t, float64(0), testutil.ToFloat64(gossipCrawlerPeersByPidCount))
require.Equal(t, float64(0), testutil.ToFloat64(gossipCrawlerTopicsCount))
})
}

View File

@@ -0,0 +1,14 @@
load("@prysm//tools/go:def.bzl", "go_library")
go_library(
name = "go_default_library",
srcs = ["interface.go"],
importpath = "github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/gossipcrawler",
visibility = [
"//visibility:public",
],
deps = [
"@com_github_ethereum_go_ethereum//p2p/enode:go_default_library",
"@com_github_libp2p_go_libp2p//core/peer:go_default_library",
],
)

View File

@@ -0,0 +1,37 @@
package gossipcrawler
import (
"context"
"github.com/ethereum/go-ethereum/p2p/enode"
"github.com/libp2p/go-libp2p/core/peer"
)
// TopicExtractor is a function that can determine the set of topics a current or potential peer
// is subscribed to based on key/value pairs from the ENR record.
type TopicExtractor func(ctx context.Context, node *enode.Node) ([]string, error)
// PeerFilterFunc defines the filtering interface used by the crawler to decide if a node
// is a valid candidate to index in the crawler.
type PeerFilterFunc func(*enode.Node) bool
type Crawler interface {
Start(te TopicExtractor) error
RemovePeerByPeerId(peerID peer.ID)
RemoveTopic(topic string)
PeersForTopic(topic string) []*enode.Node
TriggerCrawl()
}
// SubnetTopicsProvider returns the set of gossipsub topics the node
// should currently maintain peer connections for along with the minimum number of peers required
// for each topic.
type SubnetTopicsProvider func() map[string]int
// GossipDialer controls dialing peers for gossipsub topics based
// on a provided SubnetTopicsProvider and the p2p crawler.
type GossipDialer interface {
Start(provider SubnetTopicsProvider) error
DialPeersForTopicBlocking(ctx context.Context, topic string, nPeers int) error
ProtectedPeers() []peer.ID
}

View File

@@ -225,6 +225,10 @@ func (s *Service) AddDisconnectionHandler(handler func(ctx context.Context, id p
return
}
if s.crawler != nil {
s.crawler.RemovePeerByPeerId(peerID)
}
priorState, err := s.peers.ConnectionState(peerID)
if err != nil {
// Can happen if the peer has already disconnected, so...

View File

@@ -4,8 +4,8 @@ import (
"context"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/encoder"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/gossipcrawler"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/peers"
fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/interfaces"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
@@ -35,6 +35,7 @@ type (
PeersProvider
MetadataProvider
CustodyManager
Started() bool
}
// Accessor provides access to the Broadcaster, PeerManager and CustodyManager interfaces.
@@ -98,11 +99,13 @@ type (
PeerID() peer.ID
Host() host.Host
ENR() *enr.Record
GossipDialer() gossipcrawler.GossipDialer
NodeID() enode.ID
DiscoveryAddresses() ([]multiaddr.Multiaddr, error)
RefreshPersistentSubnets()
FindAndDialPeersWithSubnets(ctx context.Context, topicFormat string, digest [fieldparams.VersionLength]byte, minimumPeersPerSubnet int, subnets map[uint64]bool) error
DialPeers(ctx context.Context, maxConcurrentDials int, nodes []*enode.Node) uint
AddPingMethod(reqFunc func(ctx context.Context, id peer.ID) error)
Crawler() gossipcrawler.Crawler
}
// Sender abstracts the sending functionality from libp2p.

View File

@@ -97,6 +97,20 @@ var (
Help: "The number of data column sidecar message broadcast attempts.",
})
// Gossip Peer Crawler Metrics
gossipCrawlerPeersByEnodeCount = promauto.NewGauge(prometheus.GaugeOpts{
Name: "p2p_gossip_crawler_peers_by_enode_count",
Help: "The number of peers tracked by enode ID in the gossip peer crawler.",
})
gossipCrawlerPeersByPidCount = promauto.NewGauge(prometheus.GaugeOpts{
Name: "p2p_gossip_crawler_peers_by_pid_count",
Help: "The number of peers tracked by peer ID in the gossip peer crawler.",
})
gossipCrawlerTopicsCount = promauto.NewGauge(prometheus.GaugeOpts{
Name: "p2p_gossip_crawler_topics_count",
Help: "The number of topics tracked in the gossip peer crawler.",
})
// Gossip Tracer Metrics
pubsubTopicsActive = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "p2p_pubsub_topic_active",

View File

@@ -6,11 +6,13 @@ package p2p
import (
"context"
"crypto/ecdsa"
"fmt"
"sync"
"time"
"github.com/OffchainLabs/prysm/v7/async"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/encoder"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/gossipcrawler"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/peers"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/peers/scorers"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/types"
@@ -61,6 +63,10 @@ var (
// for the current peer limit status for the time period
// defined below.
pollingPeriod = 6 * time.Second
crawlTimeout = 12 * time.Second
crawlInterval = 1 * time.Second
maxConcurrentDials = int64(256)
)
// Service for managing peer to peer (p2p) networking.
@@ -95,6 +101,8 @@ type Service struct {
custodyInfoLock sync.RWMutex // Lock access to custodyInfo
custodyInfoSet chan struct{}
allForkDigests map[[4]byte]struct{}
crawler gossipcrawler.Crawler
gossipDialer gossipcrawler.GossipDialer
}
type custodyInfo struct {
@@ -163,7 +171,7 @@ func NewService(ctx context.Context, cfg *Config) (*Service, error) {
s.host = h
// Gossipsub registration is done before we add in any new peers
// Gossip registration is done before we add in any new peers
// due to libp2p's gossipsub implementation not taking into
// account previously added peers when creating the gossipsub
// object.
@@ -241,6 +249,25 @@ func (s *Service) Start() {
s.dv5Listener = listener
go s.listenForNewNodes()
crawler, err := NewGossipPeerCrawler(
s.ctx,
s,
s.dv5Listener,
crawlTimeout,
crawlInterval,
maxConcurrentDials,
s.filterPeer,
s.Peers().Scorers().Score,
)
if err != nil {
log.WithError(err).Fatal("Failed to create peer crawler")
s.startupErr = err
return
}
s.crawler = crawler
// Initialise the gossipsub dialer which will be started
// once the sync service is ready to provide subnet topics.
s.gossipDialer = NewGossipPeerDialer(s.ctx, s.crawler, s.PubSub().ListPeers, s.DialPeers)
}
s.started = true
@@ -311,12 +338,25 @@ func (s *Service) Start() {
func (s *Service) Stop() error {
defer s.cancel()
s.started = false
if s.dv5Listener != nil {
s.dv5Listener.Close()
}
return nil
}
// Crawler returns the p2p service's peer crawler.
func (s *Service) Crawler() gossipcrawler.Crawler {
return s.crawler
}
// GossipDialer returns the dialer responsible for maintaining
// peer counts per gossipsub topic, if discovery is enabled.
func (s *Service) GossipDialer() gossipcrawler.GossipDialer {
return s.gossipDialer
}
// Status of the p2p service. Will return an error if the service is considered unhealthy to
// indicate that this node should not serve traffic until the issue has been resolved.
func (s *Service) Status() error {
@@ -557,3 +597,80 @@ func (s *Service) downscorePeer(peerID peer.ID, reason string) {
newScore := s.Peers().Scorers().BadResponsesScorer().Increment(peerID)
log.WithFields(logrus.Fields{"peerID": peerID, "reason": reason, "newScore": newScore}).Debug("Downscore peer")
}
func AttestationSubnets(nodeID enode.ID, node *enode.Node, record *enr.Record) (map[uint64]bool, error) {
return attestationSubnets(record)
}
func SyncSubnets(nodeID enode.ID, node *enode.Node, record *enr.Record) (map[uint64]bool, error) {
return syncSubnets(record)
}
func DataColumnSubnets(nodeID enode.ID, node *enode.Node, record *enr.Record) (map[uint64]bool, error) {
return dataColumnSubnets(nodeID, record)
}
func DataColumnSubnetTopic(digest [4]byte, subnet uint64) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(DataColumnSubnetTopicFormat, digest, subnet) + e.ProtocolSuffix()
}
func SyncCommitteeSubnetTopic(digest [4]byte, subnet uint64) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(SyncCommitteeSubnetTopicFormat, digest, subnet) + e.ProtocolSuffix()
}
func AttestationSubnetTopic(digest [4]byte, subnet uint64) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(AttestationSubnetTopicFormat, digest, subnet) + e.ProtocolSuffix()
}
func BlobSubnetTopic(digest [4]byte, subnet uint64) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(BlobSubnetTopicFormat, digest, subnet) + e.ProtocolSuffix()
}
func LcOptimisticToTopic(forkDigest [4]byte) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(LightClientOptimisticUpdateTopicFormat, forkDigest) + e.ProtocolSuffix()
}
func LcFinalityToTopic(forkDigest [4]byte) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(LightClientFinalityUpdateTopicFormat, forkDigest) + e.ProtocolSuffix()
}
func BlockSubnetTopic(forkDigest [4]byte) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(BlockSubnetTopicFormat, forkDigest) + e.ProtocolSuffix()
}
func AggregateAndProofSubnetTopic(forkDigest [4]byte) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(AggregateAndProofSubnetTopicFormat, forkDigest) + e.ProtocolSuffix()
}
func VoluntaryExitSubnetTopic(forkDigest [4]byte) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(ExitSubnetTopicFormat, forkDigest) + e.ProtocolSuffix()
}
func ProposerSlashingSubnetTopic(forkDigest [4]byte) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(ProposerSlashingSubnetTopicFormat, forkDigest) + e.ProtocolSuffix()
}
func AttesterSlashingSubnetTopic(forkDigest [4]byte) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(AttesterSlashingSubnetTopicFormat, forkDigest) + e.ProtocolSuffix()
}
func SyncContributionAndProofSubnetTopic(forkDigest [4]byte) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(SyncContributionAndProofSubnetTopicFormat, forkDigest) + e.ProtocolSuffix()
}
func BlsToExecutionChangeSubnetTopic(forkDigest [4]byte) string {
e := &encoder.SszNetworkEncoder{}
return fmt.Sprintf(BlsToExecutionChangeSubnetTopicFormat, forkDigest) + e.ProtocolSuffix()
}

View File

@@ -35,7 +35,8 @@ func createHost(t *testing.T, port uint) (host.Host, *ecdsa.PrivateKey, net.IP)
ipAddr := net.ParseIP("127.0.0.1")
listen, err := multiaddr.NewMultiaddr(fmt.Sprintf("/ip4/%s/tcp/%d", ipAddr, port))
require.NoError(t, err, "Failed to p2p listen")
h, err := libp2p.New([]libp2p.Option{privKeyOption(pkey), libp2p.ListenAddrs(listen), libp2p.Security(noise.ID, noise.New)}...)
h, err := libp2p.New([]libp2p.Option{privKeyOption(pkey), libp2p.ListenAddrs(listen),
libp2p.Security(noise.ID, noise.New)}...)
require.NoError(t, err)
return h, pkey, ipAddr
}

View File

@@ -3,9 +3,6 @@ package p2p
import (
"context"
"fmt"
"maps"
"math"
"strings"
"sync"
"time"
@@ -14,19 +11,16 @@ import (
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/helpers"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags"
fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/consensus-types/wrapper"
"github.com/OffchainLabs/prysm/v7/crypto/hash"
"github.com/OffchainLabs/prysm/v7/encoding/bytesutil"
"github.com/OffchainLabs/prysm/v7/monitoring/tracing/trace"
pb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1"
"github.com/ethereum/go-ethereum/p2p/enode"
"github.com/ethereum/go-ethereum/p2p/enr"
"github.com/holiman/uint256"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
var (
@@ -61,223 +55,9 @@ const dataColumnSubnetVal = 150
const errSavingSequenceNumber = "saving sequence number after updating subnets: %w"
// nodeFilter returns a function that filters nodes based on the subnet topic and subnet index.
func (s *Service) nodeFilter(topic string, indices map[uint64]int) (func(node *enode.Node) (map[uint64]bool, error), error) {
switch {
case strings.Contains(topic, GossipAttestationMessage):
return s.filterPeerForAttSubnet(indices), nil
case strings.Contains(topic, GossipSyncCommitteeMessage):
return s.filterPeerForSyncSubnet(indices), nil
case strings.Contains(topic, GossipBlobSidecarMessage):
return s.filterPeerForBlobSubnet(indices), nil
case strings.Contains(topic, GossipDataColumnSidecarMessage):
return s.filterPeerForDataColumnsSubnet(indices), nil
default:
return nil, errors.Errorf("no subnet exists for provided topic: %s", topic)
}
}
// FindAndDialPeersWithSubnets ensures that our node is connected to at least `minimumPeersPerSubnet`
// peers for each subnet listed in `subnets`.
// If, for all subnets, the threshold is met, then this function immediately returns.
// Otherwise, it searches for new peers for defective subnets, and dials them.
// If `ctx“ is canceled while searching for peers, search is stopped, but new found peers are still dialed.
// In this case, the function returns an error.
func (s *Service) FindAndDialPeersWithSubnets(
ctx context.Context,
topicFormat string,
digest [fieldparams.VersionLength]byte,
minimumPeersPerSubnet int,
subnets map[uint64]bool,
) error {
ctx, span := trace.StartSpan(ctx, "p2p.FindAndDialPeersWithSubnet")
defer span.End()
// Return early if the discovery listener isn't set.
if s.dv5Listener == nil {
return nil
}
// Restrict dials if limit is applied.
maxConcurrentDials := math.MaxInt
if flags.MaxDialIsActive() {
maxConcurrentDials = flags.Get().MaxConcurrentDials
}
defectiveSubnets := s.defectiveSubnets(topicFormat, digest, minimumPeersPerSubnet, subnets)
for len(defectiveSubnets) > 0 {
// Stop the search/dialing loop if the context is canceled.
if err := ctx.Err(); err != nil {
return err
}
peersToDial, err := func() ([]*enode.Node, error) {
ctx, cancel := context.WithTimeout(ctx, batchPeriod)
defer cancel()
peersToDial, err := s.findPeersWithSubnets(ctx, topicFormat, digest, minimumPeersPerSubnet, defectiveSubnets)
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
return nil, errors.Wrap(err, "find peers with subnets")
}
return peersToDial, nil
}()
if err != nil {
return err
}
// Dial new peers in batches.
s.dialPeers(s.ctx, maxConcurrentDials, peersToDial)
defectiveSubnets = s.defectiveSubnets(topicFormat, digest, minimumPeersPerSubnet, subnets)
}
return nil
}
// updateDefectiveSubnets updates the defective subnets map when a node with matching subnets is found.
// It decrements the defective count for each subnet the node satisfies and removes subnets
// that are fully satisfied (count reaches 0).
func updateDefectiveSubnets(
nodeSubnets map[uint64]bool,
defectiveSubnets map[uint64]int,
) {
for subnet := range defectiveSubnets {
if !nodeSubnets[subnet] {
continue
}
defectiveSubnets[subnet]--
if defectiveSubnets[subnet] == 0 {
delete(defectiveSubnets, subnet)
}
}
}
// findPeersWithSubnets finds peers subscribed to defective subnets in batches
// until enough peers are found or the context is canceled.
// It returns new peers found during the search.
func (s *Service) findPeersWithSubnets(
ctx context.Context,
topicFormat string,
digest [fieldparams.VersionLength]byte,
minimumPeersPerSubnet int,
defectiveSubnetsOrigin map[uint64]int,
) ([]*enode.Node, error) {
// Copy the defective subnets map to avoid modifying the original map.
defectiveSubnets := make(map[uint64]int, len(defectiveSubnetsOrigin))
maps.Copy(defectiveSubnets, defectiveSubnetsOrigin)
// Create an discovery iterator to find new peers.
iterator := s.dv5Listener.RandomNodes()
// `iterator.Next` can block indefinitely. `iterator.Close` unblocks it.
// So it is important to close the iterator when the context is done to ensure
// that the search does not hang indefinitely.
go func() {
<-ctx.Done()
iterator.Close()
}()
// Retrieve the filter function that will be used to filter nodes based on the defective subnets.
filter, err := s.nodeFilter(topicFormat, defectiveSubnets)
if err != nil {
return nil, errors.Wrap(err, "node filter")
}
// Crawl the network for peers subscribed to the defective subnets.
nodeByNodeID := make(map[enode.ID]*enode.Node)
for len(defectiveSubnets) > 0 && iterator.Next() {
if err := ctx.Err(); err != nil {
// Convert the map to a slice.
peersToDial := make([]*enode.Node, 0, len(nodeByNodeID))
for _, node := range nodeByNodeID {
peersToDial = append(peersToDial, node)
}
return peersToDial, err
}
node := iterator.Node()
// Remove duplicates, keeping the node with higher seq.
existing, ok := nodeByNodeID[node.ID()]
if ok && existing.Seq() >= node.Seq() {
continue // keep existing and skip.
}
// Treat nodes that exist in nodeByNodeID with higher seq numbers as new peers
// Skip peer not matching the filter.
if !s.filterPeer(node) {
if ok {
// this means the existing peer with the lower sequence number is no longer valid
delete(nodeByNodeID, existing.ID())
// Note: We are choosing to not rollback changes to the defective subnets map in favor of calling s.defectiveSubnets once again after dialing peers.
// This is a case that should rarely happen and should be handled through a second iteration in FindAndDialPeersWithSubnets
}
continue
}
// Get all needed subnets that the node is subscribed to.
// Skip nodes that are not subscribed to any of the defective subnets.
nodeSubnets, err := filter(node)
if err != nil {
log.WithError(err).WithFields(logrus.Fields{
"nodeID": node.ID(),
"topicFormat": topicFormat,
}).Debug("Could not get needed subnets from peer")
continue
}
if len(nodeSubnets) == 0 {
continue
}
// We found a new peer. Modify the defective subnets map
// and the filter accordingly.
nodeByNodeID[node.ID()] = node
updateDefectiveSubnets(nodeSubnets, defectiveSubnets)
filter, err = s.nodeFilter(topicFormat, defectiveSubnets)
if err != nil {
return nil, errors.Wrap(err, "node filter")
}
}
// Convert the map to a slice.
peersToDial := make([]*enode.Node, 0, len(nodeByNodeID))
for _, node := range nodeByNodeID {
peersToDial = append(peersToDial, node)
}
return peersToDial, nil
}
// defectiveSubnets returns a map of subnets that have fewer than the minimum peer count.
func (s *Service) defectiveSubnets(
topicFormat string,
digest [fieldparams.VersionLength]byte,
minimumPeersPerSubnet int,
subnets map[uint64]bool,
) map[uint64]int {
missingCountPerSubnet := make(map[uint64]int, len(subnets))
for subnet := range subnets {
topic := fmt.Sprintf(topicFormat, digest, subnet) + s.Encoding().ProtocolSuffix()
peers := s.pubsub.ListPeers(topic)
peerCount := len(peers)
if peerCount < minimumPeersPerSubnet {
missingCountPerSubnet[subnet] = minimumPeersPerSubnet - peerCount
}
}
return missingCountPerSubnet
}
// dialPeers dials multiple peers concurrently up to `maxConcurrentDials` at a time.
// DialPeers dials multiple peers concurrently up to `maxConcurrentDials` at a time.
// In case of a dial failure, it logs the error but continues dialing other peers.
func (s *Service) dialPeers(ctx context.Context, maxConcurrentDials int, nodes []*enode.Node) uint {
func (s *Service) DialPeers(ctx context.Context, maxConcurrentDials int, nodes []*enode.Node) uint {
var mut sync.Mutex
counter := uint(0)
@@ -319,75 +99,13 @@ func (s *Service) dialPeers(ctx context.Context, maxConcurrentDials int, nodes [
return counter
}
// filterPeerForAttSubnet returns a method with filters peers specifically for a particular attestation subnet.
func (s *Service) filterPeerForAttSubnet(indices map[uint64]int) func(node *enode.Node) (map[uint64]bool, error) {
return func(node *enode.Node) (map[uint64]bool, error) {
if !s.filterPeer(node) {
return map[uint64]bool{}, nil
}
subnets, err := attestationSubnets(node.Record())
if err != nil {
return nil, errors.Wrap(err, "attestation subnets")
}
return intersect(indices, subnets), nil
}
}
// returns a method with filters peers specifically for a particular sync subnet.
func (s *Service) filterPeerForSyncSubnet(indices map[uint64]int) func(node *enode.Node) (map[uint64]bool, error) {
return func(node *enode.Node) (map[uint64]bool, error) {
if !s.filterPeer(node) {
return map[uint64]bool{}, nil
}
subnets, err := syncSubnets(node.Record())
if err != nil {
return nil, errors.Wrap(err, "sync subnets")
}
return intersect(indices, subnets), nil
}
}
// returns a method with filters peers specifically for a particular blob subnet.
// All peers are supposed to be subscribed to all blob subnets.
func (s *Service) filterPeerForBlobSubnet(indices map[uint64]int) func(_ *enode.Node) (map[uint64]bool, error) {
result := make(map[uint64]bool, len(indices))
for i := range indices {
result[i] = true
}
return func(_ *enode.Node) (map[uint64]bool, error) {
return result, nil
}
}
// returns a method with filters peers specifically for a particular data column subnet.
func (s *Service) filterPeerForDataColumnsSubnet(indices map[uint64]int) func(node *enode.Node) (map[uint64]bool, error) {
return func(node *enode.Node) (map[uint64]bool, error) {
if !s.filterPeer(node) {
return map[uint64]bool{}, nil
}
subnets, err := dataColumnSubnets(node.ID(), node.Record())
if err != nil {
return nil, errors.Wrap(err, "data column subnets")
}
return intersect(indices, subnets), nil
}
}
// lower threshold to broadcast object compared to searching
// for a subnet. So that even in the event of poor peer
// connectivity, we can still broadcast an attestation.
func (s *Service) hasPeerWithSubnet(subnetTopic string) bool {
func (s *Service) hasPeerWithTopic(topic string) bool {
// In the event peer threshold is lower, we will choose the lower
// threshold.
minPeers := min(1, flags.Get().MinimumPeersPerSubnet)
topic := subnetTopic + s.Encoding().ProtocolSuffix()
peersWithSubnet := s.pubsub.ListPeers(topic)
peersWithSubnetCount := len(peersWithSubnet)
@@ -712,16 +430,3 @@ func byteCount(bitCount int) int {
}
return numOfBytes
}
// interesect intersects two maps and returns a new map containing only the keys
// that are present in both maps.
func intersect(left map[uint64]int, right map[uint64]bool) map[uint64]bool {
result := make(map[uint64]bool, min(len(left), len(right)))
for i := range left {
if right[i] {
result[i] = true
}
}
return result
}

View File

@@ -3,14 +3,15 @@ package p2p
import (
"context"
"crypto/rand"
"fmt"
"testing"
"time"
"github.com/OffchainLabs/go-bitfield"
"github.com/OffchainLabs/prysm/v7/beacon-chain/cache"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/db"
testDB "github.com/OffchainLabs/prysm/v7/beacon-chain/db/testing"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/gossipcrawler"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/peers"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/peers/scorers"
testp2p "github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/testing"
@@ -24,6 +25,7 @@ import (
"github.com/ethereum/go-ethereum/p2p/enr"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/network"
require2 "github.com/stretchr/testify/require"
)
func TestStartDiscV5_FindAndDialPeersWithSubnet(t *testing.T) {
@@ -122,6 +124,21 @@ func TestStartDiscV5_FindAndDialPeersWithSubnet(t *testing.T) {
// Start the service.
service.Start()
// start the crawler with a topic extractor that maps ENR attestation subnets
// to full attestation topics for the current fork digest and encoding.
_ = service.Crawler().Start(func(ctx context.Context, node *enode.Node) ([]string, error) {
subs, err := attestationSubnets(node.Record())
if err != nil {
return nil, err
}
var topics []string
for subnet := range subs {
t := AttestationSubnetTopic(bootNodeForkDigest, subnet)
topics = append(topics, t)
}
return topics, nil
})
// Set the ENR `attnets`, used by Prysm to filter peers by subnet.
bitV := bitfield.NewBitvector64()
bitV.SetBitAt(i, true)
@@ -129,7 +146,7 @@ func TestStartDiscV5_FindAndDialPeersWithSubnet(t *testing.T) {
service.dv5Listener.LocalNode().Set(entry)
// Join and subscribe to the subnet, needed by libp2p.
topicName := fmt.Sprintf(AttestationSubnetTopicFormat, bootNodeForkDigest, i) + "/ssz_snappy"
topicName := AttestationSubnetTopic(bootNodeForkDigest, i)
topic, err := service.pubsub.Join(topicName)
require.NoError(t, err)
@@ -169,23 +186,65 @@ func TestStartDiscV5_FindAndDialPeersWithSubnet(t *testing.T) {
close(service.custodyInfoSet)
service.Start()
subnets := map[uint64]bool{1: true, 2: true, 3: true}
var topics []string
for subnet := range subnets {
t := AttestationSubnetTopic(bootNodeForkDigest, subnet)
topics = append(topics, t)
}
// start the crawler with a topic extractor that maps ENR attestation subnets
// to full attestation topics for the current fork digest and encoding.
_ = service.Crawler().Start(func(ctx context.Context, node *enode.Node) ([]string, error) {
var topics []string
subs, err := attestationSubnets(node.Record())
if err != nil {
return nil, err
}
for subnet := range subs {
t := AttestationSubnetTopic(bootNodeForkDigest, subnet)
topics = append(topics, t)
}
return topics, nil
})
defer func() {
err := service.Stop()
require.NoError(t, err)
}()
subnets := map[uint64]bool{1: true, 2: true, 3: true}
defectiveSubnets := service.defectiveSubnets(AttestationSubnetTopicFormat, bootNodeForkDigest, minimumPeersPerSubnet, subnets)
require.Equal(t, subnetCount, len(defectiveSubnets))
builder := func(idx uint64) string {
return AttestationSubnetTopic(bootNodeForkDigest, idx)
}
defectiveSubnetsCount := defectiveSubnets(service, topics, minimumPeersPerSubnet)
require.Equal(t, subnetCount, defectiveSubnetsCount)
ctxWithTimeOut, cancel := context.WithTimeout(ctx, 5*time.Second)
var topicsToDial []string
for s := range subnets {
topicsToDial = append(topicsToDial, builder(s))
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
err = service.FindAndDialPeersWithSubnets(ctxWithTimeOut, AttestationSubnetTopicFormat, bootNodeForkDigest, minimumPeersPerSubnet, subnets)
require.NoError(t, err)
for _, topic := range topicsToDial {
err = service.GossipDialer().DialPeersForTopicBlocking(ctx, topic, minimumPeersPerSubnet)
require.NoError(t, err)
}
defectiveSubnets = service.defectiveSubnets(AttestationSubnetTopicFormat, bootNodeForkDigest, minimumPeersPerSubnet, subnets)
require.Equal(t, 0, len(defectiveSubnets))
defectiveSubnetsCount = defectiveSubnets(service, topics, minimumPeersPerSubnet)
require.Equal(t, 0, defectiveSubnetsCount)
}
func defectiveSubnets(service *Service, topics []string, minimumPeersPerSubnet int) int {
count := 0
for _, topic := range topics {
peers := service.pubsub.ListPeers(topic)
if len(peers) < minimumPeersPerSubnet {
count++
}
}
return count
}
func Test_AttSubnets(t *testing.T) {
@@ -581,7 +640,6 @@ func TestFindPeersWithSubnets_NodeDeduplication(t *testing.T) {
cache.SubnetIDs.EmptyAllCaches()
defer cache.SubnetIDs.EmptyAllCaches()
ctx := context.Background()
db := testDB.SetupDB(t)
localNode1 := createTestNodeWithID(t, "node1")
@@ -742,47 +800,13 @@ func TestFindPeersWithSubnets_NodeDeduplication(t *testing.T) {
flags.Init(gFlags)
defer flags.Init(new(flags.GlobalFlags))
fakePeer := testp2p.NewTestP2P(t)
s := &Service{
cfg: &Config{
MaxPeers: 30,
DB: db,
},
genesisTime: time.Now(),
genesisValidatorsRoot: bytesutil.PadTo([]byte{'A'}, 32),
peers: peers.NewStatus(ctx, &peers.StatusConfig{
PeerLimit: 30,
ScorerParams: &scorers.Config{},
}),
host: fakePeer.BHost,
}
s := createTestService(t, db)
localNode := createTestNodeRandom(t)
mockIter := testp2p.NewMockIterator(tt.nodes)
s.dv5Listener = testp2p.NewMockListener(localNode, mockIter)
digest, err := s.currentForkDigest()
require.NoError(t, err)
ctxWithTimeout, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
result, err := s.findPeersWithSubnets(
ctxWithTimeout,
AttestationSubnetTopicFormat,
digest,
1,
tt.defectiveSubnets,
)
require.NoError(t, err, tt.description)
require.Equal(t, tt.expectedCount, len(result), tt.description)
if tt.eval != nil {
tt.eval(t, result)
}
crawler := startTestCrawler(t, s, s.dv5Listener.(*testp2p.MockListener))
verifyCrawlerPeers(t, crawler, s, tt.defectiveSubnets, tt.expectedCount, tt.description, tt.eval)
})
}
}
@@ -792,7 +816,6 @@ func TestFindPeersWithSubnets_FilterPeerRemoval(t *testing.T) {
cache.SubnetIDs.EmptyAllCaches()
defer cache.SubnetIDs.EmptyAllCaches()
ctx := context.Background()
db := testDB.SetupDB(t)
localNode1 := createTestNodeWithID(t, "node1")
@@ -945,23 +968,7 @@ func TestFindPeersWithSubnets_FilterPeerRemoval(t *testing.T) {
flags.Init(gFlags)
defer flags.Init(new(flags.GlobalFlags))
// Create test P2P instance
fakePeer := testp2p.NewTestP2P(t)
// Create mock service
s := &Service{
cfg: &Config{
MaxPeers: 30,
DB: db,
},
genesisTime: time.Now(),
genesisValidatorsRoot: bytesutil.PadTo([]byte{'A'}, 32),
peers: peers.NewStatus(ctx, &peers.StatusConfig{
PeerLimit: 30,
ScorerParams: &scorers.Config{},
}),
host: fakePeer.BHost,
}
s := createTestService(t, db)
// Mark specific node versions as "bad" to simulate filterPeer failures
for _, node := range tt.nodes {
@@ -979,30 +986,11 @@ func TestFindPeersWithSubnets_FilterPeerRemoval(t *testing.T) {
}
localNode := createTestNodeRandom(t)
mockIter := testp2p.NewMockIterator(tt.nodes)
s.dv5Listener = testp2p.NewMockListener(localNode, mockIter)
digest, err := s.currentForkDigest()
require.NoError(t, err)
ctxWithTimeout, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
result, err := s.findPeersWithSubnets(
ctxWithTimeout,
AttestationSubnetTopicFormat,
digest,
1,
tt.defectiveSubnets,
)
require.NoError(t, err, tt.description)
require.Equal(t, tt.expectedCount, len(result), tt.description)
if tt.eval != nil {
tt.eval(t, result)
}
crawler := startTestCrawler(t, s, s.dv5Listener.(*testp2p.MockListener))
verifyCrawlerPeers(t, crawler, s, tt.defectiveSubnets, tt.expectedCount, tt.description, tt.eval)
})
}
}
@@ -1046,7 +1034,6 @@ func TestFindPeersWithSubnets_received_bad_existing_node(t *testing.T) {
cache.SubnetIDs.EmptyAllCaches()
defer cache.SubnetIDs.EmptyAllCaches()
ctx := context.Background()
db := testDB.SetupDB(t)
// Create LocalNode with same ID but different sequences
@@ -1067,21 +1054,7 @@ func TestFindPeersWithSubnets_received_bad_existing_node(t *testing.T) {
flags.Init(gFlags)
defer flags.Init(new(flags.GlobalFlags))
fakePeer := testp2p.NewTestP2P(t)
service := &Service{
cfg: &Config{
MaxPeers: 30,
DB: db,
},
genesisTime: time.Now(),
genesisValidatorsRoot: bytesutil.PadTo([]byte{'A'}, 32),
peers: peers.NewStatus(ctx, &peers.StatusConfig{
PeerLimit: 30,
ScorerParams: &scorers.Config{},
}),
host: fakePeer.BHost,
}
service := createTestService(t, db)
// Create iterator with callback that marks peer as bad before processing node1_seq2
iter := &callbackIteratorForSubnets{
@@ -1105,22 +1078,80 @@ func TestFindPeersWithSubnets_received_bad_existing_node(t *testing.T) {
localNode := createTestNodeRandom(t)
service.dv5Listener = testp2p.NewMockListener(localNode, iter)
digest, err := service.currentForkDigest()
require.NoError(t, err)
crawler := startTestCrawler(t, service, service.dv5Listener.(*testp2p.MockListener))
// Run findPeersWithSubnets - node1_seq1 gets processed first, then callback marks peer bad, then node1_seq2 fails
ctxWithTimeout, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
result, err := service.findPeersWithSubnets(
ctxWithTimeout,
AttestationSubnetTopicFormat,
digest,
1,
map[uint64]int{1: 2}, // Need 2 peers for subnet 1
)
require.NoError(t, err)
require.Equal(t, 1, len(result))
require.Equal(t, localNode2.Node().ID(), result[0].ID()) // only node2 should remain
// Verification using verifyCrawlerPeers with a custom eval function
verifyCrawlerPeers(t, crawler, service, map[uint64]int{1: 1}, 1, "only node2 should remain", func(t *testing.T, result []*enode.Node) {
require.Equal(t, localNode2.Node().ID(), result[0].ID())
})
}
func createTestService(t *testing.T, d db.Database) *Service {
fakePeer := testp2p.NewTestP2P(t)
s := &Service{
cfg: &Config{
MaxPeers: 30,
DB: d,
},
genesisTime: time.Now(),
genesisValidatorsRoot: bytesutil.PadTo([]byte{'A'}, 32),
peers: peers.NewStatus(t.Context(), &peers.StatusConfig{
PeerLimit: 30,
ScorerParams: &scorers.Config{},
}),
host: fakePeer.BHost,
}
return s
}
func startTestCrawler(t *testing.T, s *Service, listener *testp2p.MockListener) *GossipPeerCrawler {
digest, err := s.currentForkDigest()
require.NoError(t, err)
crawler, err := NewGossipPeerCrawler(t.Context(), s, listener,
1*time.Second, 100*time.Millisecond, 10, gossipcrawler.PeerFilterFunc(s.filterPeer),
s.Peers().Scorers().Score)
require.NoError(t, err)
s.crawler = crawler
require.NoError(t, crawler.Start(func(ctx context.Context, n *enode.Node) ([]string, error) {
subs, err := attestationSubnets(n.Record())
if err != nil {
return nil, err
}
var topics []string
for subnet := range subs {
t := AttestationSubnetTopic(digest, subnet)
topics = append(topics, t)
}
return topics, nil
}))
return crawler
}
func verifyCrawlerPeers(t *testing.T, crawler *GossipPeerCrawler, s *Service, subnets map[uint64]int, expectedCount int, description string, eval func(t *testing.T, result []*enode.Node)) {
digest, err := s.currentForkDigest()
require.NoError(t, err)
var topics []string
for subnet := range subnets {
topics = append(topics, AttestationSubnetTopic(digest, subnet))
}
var results []*enode.Node
require2.Eventually(t, func() bool {
results = results[:0]
seen := make(map[enode.ID]struct{})
for _, topic := range topics {
peers := crawler.PeersForTopic(topic)
for _, peer := range peers {
if _, ok := seen[peer.ID()]; !ok {
seen[peer.ID()] = struct{}{}
results = append(results, peer)
}
}
}
return len(results) == expectedCount
}, 1*time.Second, 100*time.Millisecond, description)
if eval != nil {
eval(t, results)
}
}

View File

@@ -21,9 +21,9 @@ go_library(
deps = [
"//beacon-chain/core/peerdas:go_default_library",
"//beacon-chain/p2p/encoder:go_default_library",
"//beacon-chain/p2p/gossipcrawler:go_default_library",
"//beacon-chain/p2p/peers:go_default_library",
"//beacon-chain/p2p/peers/scorers:go_default_library",
"//config/fieldparams:go_default_library",
"//config/params:go_default_library",
"//consensus-types/blocks:go_default_library",
"//consensus-types/interfaces:go_default_library",

View File

@@ -4,8 +4,8 @@ import (
"context"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/encoder"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/gossipcrawler"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/peers"
fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/interfaces"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
@@ -41,6 +41,20 @@ func (*FakeP2P) AddConnectionHandler(_, _ func(ctx context.Context, id peer.ID)
}
// Crawler -- fake.
func (*FakeP2P) Crawler() gossipcrawler.Crawler {
return &MockCrawler{}
}
// GossipDialer -- fake.
func (*FakeP2P) GossipDialer() gossipcrawler.GossipDialer {
return nil
}
func (*FakeP2P) Started() bool {
return true
}
// AddDisconnectionHandler -- fake.
func (*FakeP2P) AddDisconnectionHandler(_ func(ctx context.Context, id peer.ID) error) {
}
@@ -70,11 +84,6 @@ func (*FakeP2P) DiscoveryAddresses() ([]multiaddr.Multiaddr, error) {
return nil, nil
}
// FindAndDialPeersWithSubnets mocks the p2p func.
func (*FakeP2P) FindAndDialPeersWithSubnets(ctx context.Context, topicFormat string, digest [fieldparams.VersionLength]byte, minimumPeersPerSubnet int, subnets map[uint64]bool) error {
return nil
}
// RefreshPersistentSubnets mocks the p2p func.
func (*FakeP2P) RefreshPersistentSubnets() {}
@@ -93,6 +102,11 @@ func (*FakeP2P) Peers() *peers.Status {
return nil
}
// DialPeers -- fake.
func (*FakeP2P) DialPeers(ctx context.Context, maxConcurrentDials int, nodes []*enode.Node) uint {
return 0
}
// PublishToTopic -- fake.
func (*FakeP2P) PublishToTopic(_ context.Context, _ string, _ []byte, _ ...pubsub.PubOpt) error {
return nil

View File

@@ -4,7 +4,7 @@ import (
"context"
"errors"
fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/gossipcrawler"
"github.com/ethereum/go-ethereum/p2p/enode"
"github.com/ethereum/go-ethereum/p2p/enr"
"github.com/libp2p/go-libp2p/core/host"
@@ -19,6 +19,7 @@ type MockPeerManager struct {
BHost host.Host
DiscoveryAddr []multiaddr.Multiaddr
FailDiscoveryAddr bool
Dialer gossipcrawler.GossipDialer
}
// Disconnect .
@@ -46,6 +47,11 @@ func (m MockPeerManager) NodeID() enode.ID {
return enode.ID{}
}
// GossipDialer returns the configured dialer mock, if any.
func (m MockPeerManager) GossipDialer() gossipcrawler.GossipDialer {
return m.Dialer
}
// DiscoveryAddresses .
func (m *MockPeerManager) DiscoveryAddresses() ([]multiaddr.Multiaddr, error) {
if m.FailDiscoveryAddr {
@@ -57,10 +63,15 @@ func (m *MockPeerManager) DiscoveryAddresses() ([]multiaddr.Multiaddr, error) {
// RefreshPersistentSubnets .
func (*MockPeerManager) RefreshPersistentSubnets() {}
// FindAndDialPeersWithSubnet .
func (*MockPeerManager) FindAndDialPeersWithSubnets(ctx context.Context, topicFormat string, digest [fieldparams.VersionLength]byte, minimumPeersPerSubnet int, subnets map[uint64]bool) error {
return nil
// DialPeers
func (p *MockPeerManager) DialPeers(ctx context.Context, maxConcurrentDials int, nodes []*enode.Node) uint {
return 0
}
// AddPingMethod .
func (*MockPeerManager) AddPingMethod(_ func(ctx context.Context, id peer.ID) error) {}
// Crawler.
func (*MockPeerManager) Crawler() gossipcrawler.Crawler {
return nil
}

View File

@@ -13,9 +13,9 @@ import (
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/encoder"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/gossipcrawler"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/peers"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/peers/scorers"
fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/interfaces"
@@ -66,6 +66,7 @@ type TestP2P struct {
earliestAvailableSlot primitives.Slot
custodyGroupCount uint64
enr *enr.Record
dialer gossipcrawler.GossipDialer
}
// NewTestP2P initializes a new p2p test service.
@@ -119,6 +120,10 @@ func NewTestP2PWithPubsubOptions(t *testing.T, pubsubOpts []pubsub.Option, userO
}
}
func (p *TestP2P) Status() error {
return nil
}
// Connect two test peers together.
func (p *TestP2P) Connect(b *TestP2P) {
if err := connect(p.BHost, b.BHost); err != nil {
@@ -192,11 +197,7 @@ func (p *TestP2P) ReceivePubSub(topic string, msg proto.Message) {
if _, err := p.Encoding().EncodeGossip(buf, castedMsg); err != nil {
p.t.Fatalf("Failed to encode message: %v", err)
}
digest, err := p.ForkDigest()
if err != nil {
p.t.Fatal(err)
}
topicHandle, err := ps.Join(fmt.Sprintf(topic, digest) + p.Encoding().ProtocolSuffix())
topicHandle, err := ps.Join(topic)
if err != nil {
p.t.Fatal(err)
}
@@ -288,6 +289,9 @@ func (p *TestP2P) SubscribeToTopic(topic string, opts ...pubsub.SubOpt) (*pubsub
// LeaveTopic closes topic and removes corresponding handler from list of joined topics.
// This method will return error if there are outstanding event handlers or subscriptions.
func (p *TestP2P) LeaveTopic(topic string) error {
p.mu.Lock()
defer p.mu.Unlock()
if t, ok := p.joinedTopics[topic]; ok {
if err := t.Close(); err != nil {
return err
@@ -428,9 +432,8 @@ func (p *TestP2P) Peers() *peers.Status {
return p.peers
}
// FindAndDialPeersWithSubnets mocks the p2p func.
func (*TestP2P) FindAndDialPeersWithSubnets(ctx context.Context, topicFormat string, digest [fieldparams.VersionLength]byte, minimumPeersPerSubnet int, subnets map[uint64]bool) error {
return nil
func (p *TestP2P) DialPeers(ctx context.Context, maxConcurrentDials int, nodes []*enode.Node) uint {
return 0
}
// RefreshPersistentSubnets mocks the p2p func.
@@ -567,3 +570,43 @@ func (s *TestP2P) custodyGroupCountFromPeerENR(pid peer.ID) uint64 {
return custodyGroupCount
}
// MockCrawler is a minimal mock implementation of PeerCrawler for testing
type MockCrawler struct{}
// Start does nothing as this is a mock
func (m *MockCrawler) Start(gossipcrawler.TopicExtractor) error {
return nil
}
// Stop does nothing as this is a mock
func (m *MockCrawler) Stop() {}
// SetTopicExtractor does nothing as this is a mock
func (m *MockCrawler) SetTopicExtractor(extractor func(context.Context, *enode.Node) ([]string, error)) error {
return nil
}
// RemoveTopic does nothing as this is a mock
func (m *MockCrawler) RemoveTopic(topic string) {}
// RemovePeerID does nothing as this is a mock
func (m *MockCrawler) RemovePeerByPeerId(pid peer.ID) {}
// PeersForTopic returns empty list as this is a mock
func (m *MockCrawler) PeersForTopic(topic string) []*enode.Node {
return []*enode.Node{}
}
// TriggerCrawl does nothing as this is a mock
func (m *MockCrawler) TriggerCrawl() {}
// Crawler returns a mock crawler implementation for testing.
func (*TestP2P) Crawler() gossipcrawler.Crawler {
return &MockCrawler{}
}
// GossipDialer returns nil for tests that do not exercise dialer behaviour.
func (p *TestP2P) GossipDialer() gossipcrawler.GossipDialer {
return p.dialer
}

View File

@@ -16,6 +16,8 @@ go_library(
"error.go",
"fork_watcher.go",
"fuzz_exports.go", # keep
"gossipsub_base.go",
"gossipsub_topic_family.go",
"log.go",
"metrics.go",
"once.go",
@@ -49,7 +51,11 @@ go_library(
"subscriber_handlers.go",
"subscriber_sync_committee_message.go",
"subscriber_sync_contribution_proof.go",
"subscription_controller.go",
"subscription_topic_handler.go",
"topic_families_dynamic_subnets.go",
"topic_families_static_subnets.go",
"topic_families_without_subnets.go",
"validate_aggregate_proof.go",
"validate_attester_slashing.go",
"validate_beacon_attestation.go",
@@ -137,6 +143,7 @@ go_library(
"//time/slots:go_default_library",
"@com_github_ethereum_go_ethereum//common/hexutil:go_default_library",
"@com_github_ethereum_go_ethereum//p2p/enode:go_default_library",
"@com_github_ethereum_go_ethereum//p2p/enr:go_default_library",
"@com_github_hashicorp_golang_lru//:go_default_library",
"@com_github_libp2p_go_libp2p//core:go_default_library",
"@com_github_libp2p_go_libp2p//core/host:go_default_library",
@@ -175,6 +182,8 @@ go_test(
"decode_pubsub_test.go",
"error_test.go",
"fork_watcher_test.go",
"gossipsub_base_test.go",
"gossipsub_topic_family_test.go",
"kzg_batch_verifier_test.go",
"once_test.go",
"pending_attestations_queue_bucket_test.go",
@@ -200,10 +209,11 @@ go_test(
"subscriber_beacon_aggregate_proof_test.go",
"subscriber_beacon_blocks_test.go",
"subscriber_data_column_sidecar_test.go",
"subscriber_test.go",
"subscription_controller_test.go",
"subscription_topic_handler_test.go",
"sync_fuzz_test.go",
"sync_test.go",
"topic_families_dynamic_subnets_test.go",
"validate_aggregate_proof_test.go",
"validate_attester_slashing_test.go",
"validate_beacon_attestation_test.go",
@@ -286,6 +296,7 @@ go_test(
"@com_github_d4l3k_messagediff//:go_default_library",
"@com_github_ethereum_go_ethereum//common:go_default_library",
"@com_github_ethereum_go_ethereum//core/types:go_default_library",
"@com_github_ethereum_go_ethereum//p2p/enode:go_default_library",
"@com_github_ethereum_go_ethereum//p2p/enr:go_default_library",
"@com_github_golang_snappy//:go_default_library",
"@com_github_libp2p_go_libp2p//:go_default_library",

View File

@@ -0,0 +1,398 @@
# Gossipsub Control Plane Design Document
## Overview
This branch introduces a declarative, fork-aware gossipsub control plane that manages topic subscriptions and peer discovery for subnet-based topics. The system replaces ad-hoc topic management with a structured approach centered on **Topic Families**.
### Key Components
| Component | Location | Responsibility |
|-----------|----------|----------------|
| **GossipsubController** | `sync/gossipsub_controller.go` | Orchestrates topic family lifecycle across forks |
| **GossipsubPeerCrawler** | `p2p/gossipsub_peer_crawler.go` | Discovers and indexes peers by topic via discv5 |
| **GossipsubPeerDialer** | `p2p/gossipsub_peer_controller.go` | Maintains peer connections for required topics |
| **Topic Family Abstractions** | `sync/gossipsub_topic_family.go` | Interfaces for topic subscription management |
---
## 1. Topic Family Abstraction
### 1.1 Design Goals
- **Declarative Fork Management**: Topic families declare when they activate/deactivate based on fork epochs
- **Unified Subscription Logic**: Common base handles validator registration, message loops, and cleanup
- **Dynamic vs Static Distinction**: Clear separation between global topics and subnet-based topics that change per slot
### 1.2 Interface Hierarchy
```
GossipsubTopicFamily (base)
├── Name()
├── NetworkScheduleEntry()
└── UnsubscribeAll()
GossipsubTopicFamilyWithoutDynamicSubnets
└── Subscribe() // Called once when registered
GossipsubTopicFamilyWithDynamicSubnets
├── TopicsToSubscribeForSlot(slot)
├── ExtractTopicsForNode(node) // For peer discovery
├── SubscribeForSlot(slot)
└── UnsubscribeForSlot(slot)
```
### 1.3 Implementation Categories
**Global Topics** (subscribed once per fork):
- Block, AggregateAndProof, VoluntaryExit, ProposerSlashing, AttesterSlashing
- SyncContributionAndProof (Altair+), BlsToExecutionChange (Capella+)
- LightClient updates (Altair+, feature-flagged)
**Static Per-Subnet**:
- BlobTopicFamily - One instance per blob subnet (Deneb/Electra)
**Dynamic Subnets** (change per slot based on validator duties):
- **AttestationTopicFamily** - Subnets based on attestation committee assignments
- **SyncCommitteeTopicFamily** - Subnets based on sync committee membership
- **DataColumnTopicFamily** - Subnets based on data column custody (Fulu+)
### 1.4 Base Implementation Features
`baseGossipsubTopicFamily` provides:
- **Idempotent subscriptions** - Safe to call multiple times for same topic
- **Automatic validator registration** - Registers message validator with pubsub
- **Message loop management** - Spawns goroutine to process incoming messages
- **Cleanup coordination** - Notifies crawler when topics are unsubscribed
### 1.5 Dynamic Subnet Selection
Dynamic families combine two subnet sources:
- **Subnets to Join**: Topics we must subscribe to
- **Subnets for Broadcast**: Topics we need peers for but may not subscribe to
| Family | Subnets to Join | Subnets for Broadcast |
|--------|-----------------|----------------------|
| Attestation | Persistent + aggregator subnets | Attester duty subnets |
| SyncCommittee | Active sync committee subnets | (none) |
| DataColumn | Custody column subnets | All column subnets |
### 1.6 Fork Schedule
Topic families declare activation and deactivation epochs (both are non-optional):
| Fork | Activations | Deactivations |
|------|-------------|---------------|
| Genesis | Block, AggregateAndProof, VoluntaryExit, ProposerSlashing, AttesterSlashing, Attestation | - |
| Altair | SyncContributionAndProof, SyncCommittee, [LightClient*] | - |
| Capella | BlsToExecutionChange | - |
| Deneb | Blob (6 subnets) | - |
| Electra | Blob (9 subnets) | Blob (Deneb config) |
| Fulu | DataColumn | Blob (all) |
---
## 2. GossipsubController
### 2.1 Responsibilities
- **Fork-Aware Topic Management**: Automatically subscribes/unsubscribes based on fork schedule
- **Smooth Fork Transitions**: Pre-subscribes 1 epoch before fork, unsubscribes 1 epoch after
- **Slot-Based Updates**: Updates dynamic subnet subscriptions every slot
- **Topic Extraction**: Provides interface for crawler to determine peer topic relevance
### 2.2 Lifecycle
1. **Startup**: Waits for initial sync to complete
2. **Control Loop**: Runs on slot ticker, calling `updateActiveTopicFamilies()`
3. **Shutdown**: Unsubscribes all families, cancels context
### 2.3 Fork Transition Handling
**Timeline for Fork at Epoch N:**
```
Epoch N-1: Subscribe to both old and new fork topics (overlap period)
Epoch N: Fork occurs, both topic sets remain active
Epoch N+1: Unsubscribe from old fork topics, only new fork active
```
This ensures no message loss during the transition window.
### 2.4 Update Logic (per slot)
1. **Get families for current epoch** from declarative schedule
2. **Check for upcoming fork** - if next epoch is fork boundary, include next fork's families
3. **Register new families** - add to active map, subscribe based on type:
- Static families: `Subscribe()` once
- Dynamic families: `SubscribeForSlot()` and `UnsubscribeForSlot()` every slot
4. **Remove old fork families** - if 1 epoch past fork boundary, unsubscribe and remove
### 2.5 Topic Extraction for Peer Discovery
The controller exposes `ExtractTopics(node)` which:
- Iterates all active **dynamic** subnet families
- Calls `ExtractTopicsForNode(node)` on each
- Returns deduplicated list of topics the node can serve
This is used by the peer crawler to index discovered peers by topic.
### 2.6 Topics Provider
The controller exposes `GetCurrentActiveTopics()` which:
- Returns all topics from dynamic families for the current slot
- Used by the peer dialer to know which topics need peer connections
---
## 3. GossipsubPeerCrawler
### 3.1 Purpose
Discovers peers via discv5, indexes them by topic, and verifies reachability via ping. Provides the peer dialer with a pool of verified, scored peers for each topic.
### 3.2 Key Design Decisions
**Triple Index Structure:**
- `byEnode` - Fast lookup by enode ID
- `byPeerId` - Fast lookup by libp2p peer ID
- `byTopic` - Fast lookup of peers serving a topic
**Ping-Once Guarantee:**
- A node is pinged exactly **once** regardless of ENR sequence number updates
- Prevents ping explosion when nodes frequently update their records
- Ping success sets `isPinged=true`, failure removes peer entirely
**Sequence Number Handling:**
- Only updates peer record if new sequence number is higher
- Stale records are ignored to prevent processing outdated data
### 3.3 Three Concurrent Loops
| Loop | Interval | Purpose |
|------|----------|---------|
| **crawlLoop** | `crawlInterval` | Iterates discv5 `RandomNodes()`, extracts topics, updates index |
| **pingLoop** | Continuous | Consumes ping queue, verifies reachability |
| **cleanupLoop** | 5 minutes | Prunes peers that fail filter or have no relevant topics |
### 3.4 Crawl Flow
1. Create timeout context for crawl iteration
2. Get random nodes iterator from discv5
3. For each node:
- Apply peer filter (reject bad/incompatible peers)
- Extract topics via `topicExtractor` (provided by controller)
- Update index if sequence number is newer
- Queue for ping if not already pinged and has topics
### 3.5 Ping Queue and Backpressure
- **Channel capacity**: `4 * maxConcurrentPings`
- **Backpressure**: When queue is full, crawl loop blocks on send
- **Semaphore**: Limits concurrent ping goroutines to `maxConcurrentPings`
- **Ping failure**: Removes peer from index entirely (unreachable)
- **Ping success**: Marks peer as verified (`isPinged=true`)
### 3.6 Peer Retrieval (`PeersForTopic`)
Returns peers for a topic with guarantees:
1. **Only pinged peers** - Verified reachable
2. **Filter applied** - Passes current peer filter
3. **Sorted by score** - Best peers first (using p2p scorer)
### 3.7 Peer Removal Triggers
| Trigger | Behavior |
|---------|----------|
| Ping failure | Remove immediately |
| Peer disconnection | `RemovePeerId()` called from disconnect handler |
| Topic unsubscription | `RemoveTopic()` called from base family cleanup |
| Filter rejection during crawl | Remove if previously indexed |
| Cleanup loop | Remove if no longer passes filter or has no topics |
### 3.8 Topic Extraction for Dynamic Subnets
For each dynamic family, extraction:
1. Gets subnets we currently need (union of join + broadcast)
2. Reads subnet bitfield from node's ENR record
3. Returns intersection - topics both we need AND the node advertises
---
## 4. GossipsubPeerDialer
### 4.1 Purpose
Maintains peer connections for topics we need. Works with the crawler to dial verified peers when topic peer counts fall below threshold.
### 4.2 Key Design Decisions
**Target Peer Count**: 20 peers per topic (`peerPerTopic` constant)
**Dial Loop Frequency**: Every 1 second
**Deduplication**: Peers appearing for multiple topics are only dialed once
### 4.3 Dial Flow
1. Get current topics from `topicsProvider` (controller's `GetCurrentActiveTopics`)
2. For each topic:
- Check current connected peer count via `listPeersFunc`
- If below target, calculate how many more needed
- Get peers from crawler (already filtered, scored, pinged)
- Limit to what's needed
3. Deduplicate peer list across all topics
4. Dial peers via `dialPeersFunc`
### 4.4 Blocking Dial
`DialPeersForTopicBlocking(ctx, topic, nPeers)` provides synchronous peer acquisition:
- Loops until target peer count reached or context cancelled
- Used for critical operations that need guaranteed peer connectivity
- Polls every 100ms to check connection status
---
## 5. Component Interactions
### 5.1 Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Sync Service │
│ ┌───────────────────────────────────────────────────────────────────────- │
│ │ GossipsubController | │
│ │ | │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ AttestationTF │ │ SyncCommitteeTF │ │ DataColumnTF │ │ │
│ │ │ (dynamic) │ │ (dynamic) │ │ (dynamic) │ │ │
│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │
│ │ | │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ BlockTF, etc. │ │ BlobTF (static) │ │ baseTopicFamily │ │ │
│ │ │ (global) │ │ │ │ (shared logic) │ │ │
│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │
│ │ | │
│ └──────────────────┬─────────────────────────────┬──────────────────────┘ │
│ │ │ │
│ GetCurrentActiveTopics() ExtractTopics() │
│ │ │ │
└─────────────────────┼─────────────────────────────┼─────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────┐ ┌─────────────────────────────────────┐
│ GossipsubPeerDialer │ │ GossipsubPeerCrawler │
│ │ │ │
│ - Polls topics every 1 second │ │ - Crawls discv5 periodically │
│ - Checks peer count per topic │ │ - Indexes peers by topic │
│ - Dials missing peers │ │ - Verifies via ping │
│ │ │ - Filters and scores peers │
│ │ │ │ │
│ │ PeersForTopic() │ │ │ │
│ └───────────────────────┼──┼─────────┘ │
│ │ │ │
└─────────────────────────────────┘ └──────────────────┬──────────────────┘
│ RemovePeerId()
┌──────────────────┘
┌─────────────────────────────────┐
│ P2P Service │
│ │
│ - Disconnect handler calls │
│ RemovePeerId() on crawler │
│ - Provides filterPeer, scorer │
└─────────────────────────────────┘
```
### 5.2 Data Flow Summary
| Flow | Description |
|------|-------------|
| **Discovery** | discv5 → crawlLoop → topicExtractor → crawledPeers index → pingCh |
| **Ping** | pingCh → semaphore → dv5.Ping() → isPinged=true or remove |
| **Dial** | controller topics → dialer → crawler.PeersForTopic() → dialPeers |
| **Cleanup** | disconnect/unsubscribe → RemovePeerId()/RemoveTopic() |
### 5.3 Key Invariants
**Peers from `PeersForTopic()` are always:**
- Successfully pinged (reachable)
- Passing the peer filter
- Sorted by score (best first)
**Topic subscriptions are:**
- Pre-subscribed 1 epoch before fork
- Unsubscribed 1 epoch after fork
- Updated every slot for dynamic families
**Ping behavior:**
- Each node ID pinged at most once
- Ping failures remove peer entirely
- Sequence number updates don't trigger re-ping
**Backpressure:**
- Ping queue blocks crawl when full
- Semaphore limits concurrent pings
- Natural rate limiting without explicit throttling
---
## 6. Initialization Sequence
```
PHASE 1: P2P Service Start
══════════════════════════
├─► Start discv5 listener
├─► Create GossipsubPeerCrawler (with filterPeer, scorer)
└─► Create GossipsubPeerDialer (not started yet)
PHASE 2: Sync Service Start
═══════════════════════════
├─► Create GossipsubController
└─► Launch startDiscoveryAndSubscriptions goroutine
PHASE 3: Discovery and Subscriptions (after chain start)
════════════════════════════════════════════════════════
├─► Start GossipsubController (control loop)
├─► Start Crawler with topicExtractor from controller
└─► Start Dialer with topicsProvider from controller
```
### Dependency Injection
| Component | Dependencies | Provider |
|-----------|-------------|----------|
| Crawler | discv5, filterPeer, scorer | P2P Service |
| Crawler | topicExtractor | GossipsubController |
| Dialer | crawler, listPeers, dialPeers | P2P Service |
| Dialer | topicsProvider | GossipsubController |
---
## 7. Configuration Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `crawlInterval` | configurable | How often to crawl discv5 |
| `crawlTimeout` | configurable | Max duration per crawl iteration |
| `maxConcurrentPings` | configurable | Parallel ping limit |
| `cleanupInterval` | 5 minutes | Stale peer pruning frequency |
| `peerPerTopic` | 20 | Target peer count per topic |
| `dialLoop interval` | 1 second | Topic peer check frequency |
---
## 8. Key Files
| File | Purpose |
|------|---------|
| `sync/gossipsub_controller.go` | Controller orchestrating topic families |
| `sync/gossipsub_topic_family.go` | Interface definitions and fork schedule |
| `sync/gossipsub_base.go` | Base implementation for all topic families |
| `sync/topic_families_without_subnets.go` | Global topic family implementations |
| `sync/topic_families_static_subnets.go` | Blob topic family |
| `sync/topic_families_dynamic_subnets.go` | Dynamic subnet families |
| `p2p/gossipsub_peer_crawler.go` | Peer discovery and indexing |
| `p2p/gossipsub_peer_controller.go` | Peer dialing logic |
| `p2p/gossipsubcrawler/interface.go` | Shared interfaces |
| `p2p/handshake.go` | Disconnect handler integration |

View File

@@ -1,7 +1,6 @@
package sync
import (
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/time/slots"
@@ -10,27 +9,18 @@ import (
)
// p2pHandlerControlLoop runs in a continuous loop to ensure that:
// - We are subscribed to the correct gossipsub topics (for the current and upcoming epoch).
// - We have registered the correct RPC stream handlers (for the current and upcoming epoch).
// - We have cleaned up gossipsub topics and RPC stream handlers that are no longer needed.
func (s *Service) p2pHandlerControlLoop() {
// At startup, launch registration and peer discovery loops, and register rpc stream handlers.
startEntry := params.GetNetworkScheduleEntry(s.cfg.clock.CurrentEpoch())
s.registerSubscribers(startEntry)
func (s *Service) rpcHandlerControlLoop() {
slotTicker := slots.NewSlotTicker(s.cfg.clock.GenesisTime(), params.BeaconConfig().SecondsPerSlot)
for {
select {
// In the event of a node restart, we will still end up subscribing to the correct
// topics during/after the fork epoch. This routine is to ensure correct
// subscriptions for nodes running before a fork epoch.
case <-slotTicker.C():
current := s.cfg.clock.CurrentEpoch()
if err := s.ensureRegistrationsForEpoch(current); err != nil {
if err := s.ensureRPCRegistrationsForEpoch(current); err != nil {
log.WithError(err).Error("Unable to check for fork in the next epoch")
continue
}
if err := s.ensureDeregistrationForEpoch(current); err != nil {
if err := s.ensureRPCDeregistrationForEpoch(current); err != nil {
log.WithError(err).Error("Unable to check for fork in the previous epoch")
continue
}
@@ -44,9 +34,8 @@ func (s *Service) p2pHandlerControlLoop() {
// ensureRegistrationsForEpoch ensures that gossip topic and RPC stream handler
// registrations are in place for the current and subsequent epoch.
func (s *Service) ensureRegistrationsForEpoch(epoch primitives.Epoch) error {
func (s *Service) ensureRPCRegistrationsForEpoch(epoch primitives.Epoch) error {
current := params.GetNetworkScheduleEntry(epoch)
s.registerSubscribers(current)
currentHandler, err := s.rpcHandlerByTopicFromFork(current.VersionEnum)
if err != nil {
@@ -62,7 +51,6 @@ func (s *Service) ensureRegistrationsForEpoch(epoch primitives.Epoch) error {
if current.Epoch == next.Epoch {
return nil // no fork in the next epoch
}
s.registerSubscribers(next)
if s.digestActionDone(next.ForkDigest, registerRpcOnce) {
return nil
@@ -84,7 +72,7 @@ func (s *Service) ensureRegistrationsForEpoch(epoch primitives.Epoch) error {
}
// ensureDeregistrationForEpoch deregisters appropriate gossip and RPC topic if there is a fork in the current epoch.
func (s *Service) ensureDeregistrationForEpoch(currentEpoch primitives.Epoch) error {
func (s *Service) ensureRPCDeregistrationForEpoch(currentEpoch primitives.Epoch) error {
current := params.GetNetworkScheduleEntry(currentEpoch)
// If we are still in our genesis fork version then exit early.
@@ -115,20 +103,5 @@ func (s *Service) ensureDeregistrationForEpoch(currentEpoch primitives.Epoch) er
}
}
// Unsubscribe from all gossip topics with the previous fork digest.
if s.digestActionDone(previous.ForkDigest, unregisterGossipOnce) {
return nil
}
for _, t := range s.subHandler.allTopics() {
retDigest, err := p2p.ExtractGossipDigest(t)
if err != nil {
log.WithError(err).Error("Could not retrieve digest")
continue
}
if retDigest == previous.ForkDigest {
s.unSubscribeFromTopic(t)
}
}
return nil
}

View File

@@ -2,8 +2,6 @@ package sync
import (
"context"
"fmt"
"sync"
"testing"
"time"
@@ -44,36 +42,11 @@ func testForkWatcherService(t *testing.T, current primitives.Epoch) *Service {
initialSync: &mockSync.Sync{IsSyncing: false},
},
chainStarted: abool.New(),
subHandler: newSubTopicHandler(),
initialSyncComplete: closedChan,
}
return r
}
func TestRegisterSubscriptions_Idempotent(t *testing.T) {
params.SetupTestConfigCleanup(t)
genesis.StoreEmbeddedDuringTest(t, params.BeaconConfig().ConfigName)
fulu := params.BeaconConfig().ElectraForkEpoch + 4096*2
params.BeaconConfig().FuluForkEpoch = fulu
params.BeaconConfig().InitializeForkSchedule()
current := fulu - 1
s := testForkWatcherService(t, current)
next := params.GetNetworkScheduleEntry(fulu)
wg := attachSpawner(s)
require.Equal(t, true, s.registerSubscribers(next))
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for subscriptions to be registered")
case <-done:
}
// the goal of this callback is just to assert that spawn is never called.
s.subscriptionSpawner = func(func()) { t.Error("registration routines spawned twice for the same digest") }
require.NoError(t, s.ensureRegistrationsForEpoch(fulu))
}
func TestService_CheckForNextEpochFork(t *testing.T) {
closedChan := make(chan struct{})
close(closedChan)
@@ -103,7 +76,6 @@ func TestService_CheckForNextEpochFork(t *testing.T) {
epochAtRegistration: func(e primitives.Epoch) primitives.Epoch { return e - 1 },
nextForkEpoch: params.BeaconConfig().BellatrixForkEpoch,
checkRegistration: func(t *testing.T, s *Service) {
digest := params.ForkDigest(params.BeaconConfig().AltairForkEpoch)
rpcMap := make(map[string]bool)
for _, p := range s.cfg.p2p.Host().Mux().Protocols() {
rpcMap[string(p)] = true
@@ -111,8 +83,6 @@ func TestService_CheckForNextEpochFork(t *testing.T) {
assert.Equal(t, true, rpcMap[p2p.RPCBlocksByRangeTopicV2+s.cfg.p2p.Encoding().ProtocolSuffix()], "topic doesn't exist")
assert.Equal(t, true, rpcMap[p2p.RPCBlocksByRootTopicV2+s.cfg.p2p.Encoding().ProtocolSuffix()], "topic doesn't exist")
assert.Equal(t, true, rpcMap[p2p.RPCMetaDataTopicV2+s.cfg.p2p.Encoding().ProtocolSuffix()], "topic doesn't exist")
expected := fmt.Sprintf(p2p.SyncContributionAndProofSubnetTopicFormat+s.cfg.p2p.Encoding().ProtocolSuffix(), digest)
assert.Equal(t, true, s.subHandler.topicExists(expected), "subnet topic doesn't exist")
// TODO: we should check subcommittee indices here but we need to work with the committee cache to do it properly
/*
subIndices := mapFromCount(params.BeaconConfig().SyncCommitteeSubnetCount)
@@ -127,14 +97,10 @@ func TestService_CheckForNextEpochFork(t *testing.T) {
{
name: "capella fork in the next epoch",
checkRegistration: func(t *testing.T, s *Service) {
digest := params.ForkDigest(params.BeaconConfig().CapellaForkEpoch)
rpcMap := make(map[string]bool)
for _, p := range s.cfg.p2p.Host().Mux().Protocols() {
rpcMap[string(p)] = true
}
expected := fmt.Sprintf(p2p.BlsToExecutionChangeSubnetTopicFormat+s.cfg.p2p.Encoding().ProtocolSuffix(), digest)
assert.Equal(t, true, s.subHandler.topicExists(expected), "subnet topic doesn't exist")
},
forkEpoch: params.BeaconConfig().CapellaForkEpoch,
nextForkEpoch: params.BeaconConfig().DenebForkEpoch,
@@ -143,17 +109,10 @@ func TestService_CheckForNextEpochFork(t *testing.T) {
{
name: "deneb fork in the next epoch",
checkRegistration: func(t *testing.T, s *Service) {
digest := params.ForkDigest(params.BeaconConfig().DenebForkEpoch)
rpcMap := make(map[string]bool)
for _, p := range s.cfg.p2p.Host().Mux().Protocols() {
rpcMap[string(p)] = true
}
subIndices := mapFromCount(params.BeaconConfig().BlobsidecarSubnetCount)
for idx := range subIndices {
topic := fmt.Sprintf(p2p.BlobSubnetTopicFormat, digest, idx)
expected := topic + s.cfg.p2p.Encoding().ProtocolSuffix()
assert.Equal(t, true, s.subHandler.topicExists(expected), fmt.Sprintf("subnet topic %s doesn't exist", expected))
}
assert.Equal(t, true, rpcMap[p2p.RPCBlobSidecarsByRangeTopicV1+s.cfg.p2p.Encoding().ProtocolSuffix()], "topic doesn't exist")
assert.Equal(t, true, rpcMap[p2p.RPCBlobSidecarsByRootTopicV1+s.cfg.p2p.Encoding().ProtocolSuffix()], "topic doesn't exist")
},
@@ -162,16 +121,8 @@ func TestService_CheckForNextEpochFork(t *testing.T) {
epochAtRegistration: func(e primitives.Epoch) primitives.Epoch { return e - 1 },
},
{
name: "electra fork in the next epoch",
checkRegistration: func(t *testing.T, s *Service) {
digest := params.ForkDigest(params.BeaconConfig().ElectraForkEpoch)
subIndices := mapFromCount(params.BeaconConfig().BlobsidecarSubnetCountElectra)
for idx := range subIndices {
topic := fmt.Sprintf(p2p.BlobSubnetTopicFormat, digest, idx)
expected := topic + s.cfg.p2p.Encoding().ProtocolSuffix()
assert.Equal(t, true, s.subHandler.topicExists(expected), fmt.Sprintf("subnet topic %s doesn't exist", expected))
}
},
name: "electra fork in the next epoch",
checkRegistration: func(t *testing.T, s *Service) {},
forkEpoch: params.BeaconConfig().ElectraForkEpoch,
nextForkEpoch: params.BeaconConfig().FuluForkEpoch,
epochAtRegistration: func(e primitives.Epoch) primitives.Epoch { return e - 1 },
@@ -194,52 +145,28 @@ func TestService_CheckForNextEpochFork(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
current := tt.epochAtRegistration(tt.forkEpoch)
s := testForkWatcherService(t, current)
wg := attachSpawner(s)
require.NoError(t, s.ensureRegistrationsForEpoch(s.cfg.clock.CurrentEpoch()))
wg.Wait()
require.NoError(t, s.ensureRPCRegistrationsForEpoch(s.cfg.clock.CurrentEpoch()))
tt.checkRegistration(t, s)
if current != tt.forkEpoch-1 {
return
}
// Ensure the topics were registered for the upcoming fork
digest := params.ForkDigest(tt.forkEpoch)
assert.Equal(t, true, s.subHandler.digestExists(digest))
// After this point we are checking deregistration, which doesn't apply if there isn't a higher
// nextForkEpoch.
if tt.forkEpoch >= tt.nextForkEpoch {
return
}
nextDigest := params.ForkDigest(tt.nextForkEpoch)
// Move the clock to just before the next fork epoch and ensure deregistration is correct
wg = attachSpawner(s)
s.cfg.clock = defaultClockWithTimeAtEpoch(tt.nextForkEpoch - 1)
require.NoError(t, s.ensureRegistrationsForEpoch(s.cfg.clock.CurrentEpoch()))
wg.Wait()
require.NoError(t, s.ensureRPCRegistrationsForEpoch(s.cfg.clock.CurrentEpoch()))
require.NoError(t, s.ensureDeregistrationForEpoch(tt.nextForkEpoch))
assert.Equal(t, true, s.subHandler.digestExists(digest))
// deregister as if it is the epoch after the next fork epoch
require.NoError(t, s.ensureDeregistrationForEpoch(tt.nextForkEpoch+1))
assert.Equal(t, false, s.subHandler.digestExists(digest))
assert.Equal(t, true, s.subHandler.digestExists(nextDigest))
require.NoError(t, s.ensureRPCDeregistrationForEpoch(tt.nextForkEpoch))
})
}
}
func attachSpawner(s *Service) *sync.WaitGroup {
wg := new(sync.WaitGroup)
s.subscriptionSpawner = func(f func()) {
wg.Go(func() {
f()
})
}
return wg
}
// oneEpoch returns the duration of one epoch.
func oneEpoch() time.Duration {
return time.Duration(params.BeaconConfig().SlotsPerEpoch.Mul(params.BeaconConfig().SecondsPerSlot)) * time.Second

View File

@@ -0,0 +1,230 @@
package sync
import (
"context"
"fmt"
"runtime/debug"
"sync"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/monitoring/tracing"
"github.com/OffchainLabs/prysm/v7/monitoring/tracing/trace"
pubsub "github.com/libp2p/go-libp2p-pubsub"
"github.com/pkg/errors"
"google.golang.org/protobuf/proto"
)
type baseTopicFamily struct {
syncService *Service
nse params.NetworkScheduleEntry
validator wrappedVal
handler subHandler
tf TopicFamily
mu sync.Mutex
subscriptions map[string]*pubsub.Subscription
}
func newBaseTopicFamily(syncService *Service, nse params.NetworkScheduleEntry, validator wrappedVal,
handler subHandler, tf TopicFamily) *baseTopicFamily {
return &baseTopicFamily{
syncService: syncService,
nse: nse,
validator: validator,
handler: handler,
tf: tf,
subscriptions: make(map[string]*pubsub.Subscription),
}
}
func (b *baseTopicFamily) NetworkScheduleEntry() params.NetworkScheduleEntry {
return b.nse
}
// subscribeToTopics subscribes to the given list of gossipsub topics.
//
// This method is idempotent for a given topic - if a subscription already exists for a topic,
// it will be skipped without error. This allows callers to safely call subscribeToTopics
// multiple times with overlapping topic lists without creating duplicate subscriptions.
//
// For each new topic subscription, this method:
// 1. Registers a topic validator with the pubsub system
// 2. Creates the subscription via the p2p layer
// 3. Spawns a goroutine running a message loop that processes incoming messages
// 4. Tracks the subscription in the internal subscriptions map
//
// The message loop for each subscription runs until the context is cancelled or an error
// occurs. Each received message is processed in its own goroutine with panic recovery.
//
// Errors during subscription (validator registration failures, subscription failures) are
// logged but do not prevent other topics from being subscribed to.
func (b *baseTopicFamily) subscribeToTopics(topics []string) {
b.mu.Lock()
defer b.mu.Unlock()
for _, topic := range topics {
log := log.WithField("topic", topic)
s := b.syncService
// Do not resubscribe to topics that we already have a subscription for.
_, ok := b.subscriptions[topic]
if ok {
continue
}
if err := s.cfg.p2p.PubSub().RegisterTopicValidator(s.wrapAndReportValidation(topic, b.validator)); err != nil {
log.WithError(err).Error("Could not register validator for topic")
continue
}
sub, err := s.cfg.p2p.SubscribeToTopic(topic)
if err != nil {
// Any error subscribing to a PubSub topic would be the result of a misconfiguration of
// libp2p PubSub library or a subscription request to a topic that fails to match the topic
// subscription filter.
log.WithError(err).Error("Could not subscribe topic")
continue
}
// Pipeline decodes the incoming subscription data, runs the validation, and handles the
// message.
pipeline := func(msg *pubsub.Message) {
ctx, cancel := context.WithTimeout(s.ctx, pubsubMessageTimeout)
defer cancel()
ctx, span := trace.StartSpan(ctx, "sync.pubsub")
defer span.End()
defer func() {
if r := recover(); r != nil {
tracing.AnnotateError(span, fmt.Errorf("panic occurred: %v", r))
log.WithField("error", r).
WithField("recoveredAt", "subscribeWithBase").
WithField("stack", string(debug.Stack())).
Error("Panic occurred")
}
}()
span.SetAttributes(trace.StringAttribute("topic", topic))
if msg.ValidatorData == nil {
log.Error("Received nil message on pubsub")
messageFailedProcessingCounter.WithLabelValues(topic).Inc()
return
}
if err := b.handler(ctx, msg.ValidatorData.(proto.Message)); err != nil {
tracing.AnnotateError(span, err)
log.WithError(err).Error("Could not handle p2p pubsub")
messageFailedProcessingCounter.WithLabelValues(topic).Inc()
return
}
}
// The main message loop for receiving incoming messages from this subscription.
messageLoop := func() {
for {
msg, err := sub.Next(s.ctx)
if err != nil {
// This should only happen when the context is cancelled or subscription is cancelled.
if !errors.Is(err, pubsub.ErrSubscriptionCancelled) { // Only log a warning on unexpected errors.
log.WithError(err).Warn("Subscription next failed")
}
// Cancel subscription in the event of an error, as we are
// now exiting topic event loop.
sub.Cancel()
return
}
if msg.ReceivedFrom == s.cfg.p2p.PeerID() {
continue
}
go pipeline(msg)
}
}
go messageLoop()
log.WithField("topic", topic).Info("Subscribed to")
b.subscriptions[topic] = sub
s.subHandler.addTopic(topic, sub)
}
}
// UnsubscribeAll unsubscribes from all topics managed by this topic family.
//
// This method iterates through all active subscriptions and performs cleanup for each:
// - Unregisters the topic validator from pubsub
// - Cancels the subscription (stopping the message loop goroutine)
// - Leaves the topic in the p2p layer
// - Removes the topic from the crawler's tracking (if crawler is configured)
// - Removes the subscription from internal tracking
//
// After this method returns, the topic family has no active subscriptions.
// This is typically called during shutdown or when transitioning between network forks.
func (b *baseTopicFamily) UnsubscribeAll() {
b.mu.Lock()
defer b.mu.Unlock()
for topic, sub := range b.subscriptions {
b.cleanupSubscription(topic, sub)
delete(b.subscriptions, topic)
}
}
// pruneTopicsExcept unsubscribes from all topics except those in the provided list.
//
// This method is used to efficiently manage dynamic subnet subscriptions. When the set of
// required topics changes (e.g., due to slot progression or validator duty changes), this
// method removes subscriptions that are no longer needed while preserving active ones.
//
// Parameters:
// - wantedTopics: List of topic strings that should remain subscribed. Any topic not in
// this list will be unsubscribed and cleaned up.
//
// For each topic being pruned, the cleanup process:
// - Unregisters the topic validator from pubsub
// - Cancels the subscription (stopping the message loop goroutine)
// - Leaves the topic in the p2p layer
// - Removes the topic from the crawler's tracking (if crawler is configured)
// - Removes the subscription from internal tracking
//
// This method is safe to call with an empty wantedTopics list, which will unsubscribe from
// all topics (equivalent to UnsubscribeAll).
func (b *baseTopicFamily) pruneTopicsExcept(wantedTopics []string) {
b.mu.Lock()
defer b.mu.Unlock()
neededMap := make(map[string]bool, len(wantedTopics))
for _, t := range wantedTopics {
neededMap[t] = true
}
for topic, sub := range b.subscriptions {
if !neededMap[topic] {
b.cleanupSubscription(topic, sub)
}
}
}
func (b *baseTopicFamily) cleanupSubscription(topic string, sub *pubsub.Subscription) {
s := b.syncService
log.WithField("topic", topic).Info("Unsubscribed from")
if err := s.cfg.p2p.PubSub().UnregisterTopicValidator(topic); err != nil {
log.WithError(err).Error("Could not unregister topic validator")
}
if sub != nil {
sub.Cancel()
}
if err := s.cfg.p2p.LeaveTopic(topic); err != nil {
log.WithError(err).Error("Unable to leave topic")
}
if crawler := s.cfg.p2p.Crawler(); crawler != nil {
crawler.RemoveTopic(topic)
}
delete(b.subscriptions, topic)
s.subHandler.removeTopic(topic)
}

View File

@@ -3,7 +3,6 @@ package sync
import (
"context"
"fmt"
"reflect"
"sync"
"testing"
"time"
@@ -33,7 +32,6 @@ import (
pubsub "github.com/libp2p/go-libp2p-pubsub"
pubsubpb "github.com/libp2p/go-libp2p-pubsub/pb"
"github.com/libp2p/go-libp2p/core/peer"
logTest "github.com/sirupsen/logrus/hooks/test"
"google.golang.org/protobuf/proto"
)
@@ -60,11 +58,10 @@ func TestSubscribe_ReceivesValidMessage(t *testing.T) {
require.NoError(t, err)
nse := params.GetNetworkScheduleEntry(r.cfg.clock.CurrentEpoch())
p2pService.Digest = nse.ForkDigest
topic := "/eth2/%x/voluntary_exit"
var wg sync.WaitGroup
wg.Add(1)
r.subscribe(topic, r.noopValidator, func(_ context.Context, msg proto.Message) error {
handler := func(_ context.Context, msg proto.Message) error {
m, ok := msg.(*pb.SignedVoluntaryExit)
assert.Equal(t, true, ok, "Object is not of type *pb.SignedVoluntaryExit")
if m.Exit == nil || m.Exit.Epoch != 55 {
@@ -72,10 +69,15 @@ func TestSubscribe_ReceivesValidMessage(t *testing.T) {
}
wg.Done()
return nil
}, nse)
r.markForChainStart()
}
p2pService.ReceivePubSub(topic, &pb.SignedVoluntaryExit{Exit: &pb.VoluntaryExit{Epoch: 55}, Signature: make([]byte, fieldparams.BLSSignatureLength)})
tf := NewVoluntaryExitTopicFamily(&r, nse)
base := newBaseTopicFamily(&r, nse, r.noopValidator, handler, tf)
tf.baseTopicFamily = base
tf.Subscribe()
r.markForChainStart()
p2pService.ReceivePubSub(tf.getFullTopicString(), &pb.SignedVoluntaryExit{Exit: &pb.VoluntaryExit{Epoch: 55}, Signature: make([]byte, fieldparams.BLSSignatureLength)})
if util.WaitTimeout(&wg, time.Second) {
t.Fatal("Did not receive PubSub in 1 second")
@@ -110,19 +112,22 @@ func TestSubscribe_UnsubscribeTopic(t *testing.T) {
p2pService.Digest = nse.ForkDigest
topic := "/eth2/%x/voluntary_exit"
r.subscribe(topic, r.noopValidator, func(_ context.Context, msg proto.Message) error {
return nil
}, nse)
tf := staticTopicFamily{
name: "VoluntaryExitTopicFamily",
topics: []string{topic},
}
base := newBaseTopicFamily(&r, nse, r.noopValidator, noopHandler, &tf)
tf.baseTopicFamily = base
tf.Subscribe()
r.markForChainStart()
fullTopic := fmt.Sprintf(topic, p2pService.Digest) + p2pService.Encoding().ProtocolSuffix()
assert.Equal(t, true, r.subHandler.topicExists(fullTopic))
assert.Equal(t, true, r.subHandler.topicExists(topic))
topics := p2pService.PubSub().GetTopics()
assert.Equal(t, fullTopic, topics[0])
assert.Equal(t, topic, topics[0])
r.unSubscribeFromTopic(fullTopic)
tf.UnsubscribeAll()
assert.Equal(t, false, r.subHandler.topicExists(fullTopic))
assert.Equal(t, false, r.subHandler.topicExists(topic))
assert.Equal(t, 0, len(p2pService.PubSub().GetTopics()))
}
@@ -157,16 +162,20 @@ func TestSubscribe_ReceivesAttesterSlashing(t *testing.T) {
subHandler: newSubTopicHandler(),
}
markInitSyncComplete(t, &r)
topic := "/eth2/%x/attester_slashing"
var wg sync.WaitGroup
wg.Add(1)
nse := params.GetNetworkScheduleEntry(r.cfg.clock.CurrentEpoch())
p2pService.Digest = nse.ForkDigest
r.subscribe(topic, r.noopValidator, func(ctx context.Context, msg proto.Message) error {
tf := NewAttesterSlashingTopicFamily(&r, nse)
tf.baseTopicFamily.validator = r.noopValidator
tf.baseTopicFamily.handler = func(ctx context.Context, msg proto.Message) error {
require.NoError(t, r.attesterSlashingSubscriber(ctx, msg))
wg.Done()
return nil
}, nse)
}
tf.Subscribe()
beaconState, privKeys := util.DeterministicGenesisState(t, 64)
chainService.State = beaconState
r.markForChainStart()
@@ -178,7 +187,7 @@ func TestSubscribe_ReceivesAttesterSlashing(t *testing.T) {
require.NoError(t, err, "Error generating attester slashing")
err = r.cfg.beaconDB.SaveState(ctx, beaconState, bytesutil.ToBytes32(attesterSlashing.FirstAttestation().GetData().BeaconBlockRoot))
require.NoError(t, err)
p2pService.ReceivePubSub(topic, attesterSlashing)
p2pService.ReceivePubSub(tf.getFullTopicString(), attesterSlashing)
if util.WaitTimeout(&wg, time.Second) {
t.Fatal("Did not receive PubSub in 1 second")
@@ -210,18 +219,22 @@ func TestSubscribe_ReceivesProposerSlashing(t *testing.T) {
subHandler: newSubTopicHandler(),
}
markInitSyncComplete(t, &r)
topic := "/eth2/%x/proposer_slashing"
var wg sync.WaitGroup
wg.Add(1)
params.SetupTestConfigCleanup(t)
params.OverrideBeaconConfig(params.MainnetConfig())
nse := params.GetNetworkScheduleEntry(r.cfg.clock.CurrentEpoch())
p2pService.Digest = nse.ForkDigest
r.subscribe(topic, r.noopValidator, func(ctx context.Context, msg proto.Message) error {
tf := NewProposerSlashingTopicFamily(&r, nse)
tf.baseTopicFamily.validator = r.noopValidator
tf.baseTopicFamily.handler = func(ctx context.Context, msg proto.Message) error {
require.NoError(t, r.proposerSlashingSubscriber(ctx, msg))
wg.Done()
return nil
}, nse)
}
tf.Subscribe()
beaconState, privKeys := util.DeterministicGenesisState(t, 64)
chainService.State = beaconState
r.markForChainStart()
@@ -232,7 +245,7 @@ func TestSubscribe_ReceivesProposerSlashing(t *testing.T) {
)
require.NoError(t, err, "Error generating proposer slashing")
p2pService.ReceivePubSub(topic, proposerSlashing)
p2pService.ReceivePubSub(tf.getFullTopicString(), proposerSlashing)
if util.WaitTimeout(&wg, time.Second) {
t.Fatal("Did not receive PubSub in 1 second")
@@ -262,70 +275,27 @@ func TestSubscribe_HandlesPanic(t *testing.T) {
nse := params.GetNetworkScheduleEntry(r.cfg.clock.CurrentEpoch())
p.Digest = nse.ForkDigest
topic := p2p.GossipTypeMapping[reflect.TypeFor[*pb.SignedVoluntaryExit]()]
var wg sync.WaitGroup
wg.Add(1)
r.subscribe(topic, r.noopValidator, func(_ context.Context, msg proto.Message) error {
tf := NewVoluntaryExitTopicFamily(&r, nse)
handler := func(_ context.Context, msg proto.Message) error {
defer wg.Done()
panic("bad")
}, nse)
}
base := newBaseTopicFamily(&r, nse, r.noopValidator, handler, tf)
tf.baseTopicFamily = base
tf.Subscribe()
r.markForChainStart()
p.ReceivePubSub(topic, &pb.SignedVoluntaryExit{Exit: &pb.VoluntaryExit{Epoch: 55}, Signature: make([]byte, fieldparams.BLSSignatureLength)})
p.ReceivePubSub(tf.getFullTopicString(), &pb.SignedVoluntaryExit{Exit: &pb.VoluntaryExit{Epoch: 55}, Signature: make([]byte, fieldparams.BLSSignatureLength)})
if util.WaitTimeout(&wg, time.Second) {
t.Fatal("Did not receive PubSub in 1 second")
}
}
func TestRevalidateSubscription_CorrectlyFormatsTopic(t *testing.T) {
p := p2ptest.NewTestP2P(t)
hook := logTest.NewGlobal()
chain := &mockChain.ChainService{
Genesis: time.Now(),
ValidatorsRoot: [32]byte{'A'},
}
r := Service{
ctx: t.Context(),
cfg: &config{
chain: chain,
clock: startup.NewClock(chain.Genesis, chain.ValidatorsRoot),
p2p: p,
},
chainStarted: abool.New(),
subHandler: newSubTopicHandler(),
}
nse := params.GetNetworkScheduleEntry(r.cfg.clock.CurrentEpoch())
params := subscribeParameters{
topicFormat: "/eth2/testing/%#x/committee%d",
nse: nse,
}
tracker := newSubnetTracker(params)
// committee index 1
c1 := uint64(1)
fullTopic := params.fullTopic(c1, r.cfg.p2p.Encoding().ProtocolSuffix())
_, topVal := r.wrapAndReportValidation(fullTopic, r.noopValidator)
require.NoError(t, r.cfg.p2p.PubSub().RegisterTopicValidator(fullTopic, topVal))
sub1, err := r.cfg.p2p.SubscribeToTopic(fullTopic)
require.NoError(t, err)
tracker.track(c1, sub1)
// committee index 2
c2 := uint64(2)
fullTopic = params.fullTopic(c2, r.cfg.p2p.Encoding().ProtocolSuffix())
_, topVal = r.wrapAndReportValidation(fullTopic, r.noopValidator)
err = r.cfg.p2p.PubSub().RegisterTopicValidator(fullTopic, topVal)
require.NoError(t, err)
sub2, err := r.cfg.p2p.SubscribeToTopic(fullTopic)
require.NoError(t, err)
tracker.track(c2, sub2)
r.pruneNotWanted(tracker, map[uint64]bool{c2: true})
require.LogsDoNotContain(t, hook, "Could not unregister topic validator")
}
func Test_wrapAndReportValidation(t *testing.T) {
mChain := &mockChain.ChainService{
Genesis: time.Now(),
@@ -446,11 +416,15 @@ func TestFilterSubnetPeers(t *testing.T) {
cfg.SlotDurationMilliseconds = 1000
params.OverrideBeaconConfig(cfg)
// Save the current flags to restore them after the test
resetFlags := flags.Get()
defer func() {
flags.Init(resetFlags)
}()
gFlags := new(flags.GlobalFlags)
gFlags.MinimumPeersPerSubnet = 4
flags.Init(gFlags)
// Reset config.
defer flags.Init(new(flags.GlobalFlags))
p := p2ptest.NewTestP2P(t)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
@@ -480,6 +454,7 @@ func TestFilterSubnetPeers(t *testing.T) {
chainStarted: abool.New(),
subHandler: newSubTopicHandler(),
}
r.subscriptionController = NewSubscriptionController(ctx, &r)
markInitSyncComplete(t, &r)
// Empty cache at the end of the test.
defer cache.SubnetIDs.EmptyAllCaches()
@@ -553,11 +528,12 @@ func TestSubscribeWithSyncSubnets_DynamicOK(t *testing.T) {
currEpoch := slots.ToEpoch(slot)
cache.SyncSubnetIDs.AddSyncCommitteeSubnets([]byte("pubkey"), currEpoch, []uint64{0, 1}, 10*time.Second)
nse := params.GetNetworkScheduleEntry(r.cfg.clock.CurrentEpoch())
go r.subscribeWithParameters(subscribeParameters{
topicFormat: p2p.SyncCommitteeSubnetTopicFormat,
nse: nse,
getSubnetsToJoin: r.activeSyncSubnetIndices,
})
tfDyn := NewSyncCommitteeTopicFamily(&r, nse)
base := newBaseTopicFamily(&r, nse, r.noopValidator, noopHandler, tfDyn)
tfDyn.baseTopicFamily = base
tfDyn.SubscribeForSlot(slot)
time.Sleep(2 * time.Second)
assert.Equal(t, 2, len(r.cfg.p2p.PubSub().GetTopics()))
topicMap := map[string]bool{}
@@ -602,12 +578,11 @@ func TestSubscribeWithSyncSubnets_DynamicSwitchFork(t *testing.T) {
require.Equal(t, [4]byte(params.BeaconConfig().DenebForkVersion), nse.ForkVersion)
require.Equal(t, params.BeaconConfig().DenebForkEpoch, nse.Epoch)
sp := newSubnetTracker(subscribeParameters{
topicFormat: p2p.SyncCommitteeSubnetTopicFormat,
nse: nse,
getSubnetsToJoin: r.activeSyncSubnetIndices,
})
r.trySubscribeSubnets(sp)
tfDyn2 := NewSyncCommitteeTopicFamily(&r, nse)
base := newBaseTopicFamily(&r, nse, r.noopValidator, noopHandler, tfDyn2)
tfDyn2.baseTopicFamily = base
tfDyn2.SubscribeForSlot(r.cfg.clock.CurrentSlot())
assert.Equal(t, 2, len(r.cfg.p2p.PubSub().GetTopics()))
topicMap := map[string]bool{}
for _, t := range r.cfg.p2p.PubSub().GetTopics() {
@@ -626,11 +601,14 @@ func TestSubscribeWithSyncSubnets_DynamicSwitchFork(t *testing.T) {
require.Equal(t, [4]byte(params.BeaconConfig().ElectraForkVersion), nse.ForkVersion)
require.Equal(t, params.BeaconConfig().ElectraForkEpoch, nse.Epoch)
sp.nse = nse
tfDyn2.nse = nse
// clear the cache and re-subscribe to subnets.
// this should result in the subscriptions being removed
cache.SyncSubnetIDs.EmptyAllCaches()
r.trySubscribeSubnets(sp)
slot := r.cfg.clock.CurrentSlot()
tfDyn2.UnsubscribeForSlot(slot)
tfDyn2.SubscribeForSlot(r.cfg.clock.CurrentSlot())
assert.Equal(t, 0, len(r.cfg.p2p.PubSub().GetTopics()))
}

View File

@@ -0,0 +1,144 @@
package sync
import (
"context"
"github.com/OffchainLabs/prysm/v7/config/features"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/ethereum/go-ethereum/p2p/enode"
pubsub "github.com/libp2p/go-libp2p-pubsub"
"github.com/libp2p/go-libp2p/core/peer"
"google.golang.org/protobuf/proto"
)
// wrappedVal represents a gossip validator which also returns an error along with the result.
type wrappedVal func(context.Context, peer.ID, *pubsub.Message) (pubsub.ValidationResult, error)
// subHandler represents handler for a given subscription.
type subHandler func(context.Context, proto.Message) error
// noopHandler is used for subscriptions that do not require anything to be done.
var noopHandler subHandler = func(ctx context.Context, msg proto.Message) error {
return nil
}
type TopicFamily interface {
Name() string
NetworkScheduleEntry() params.NetworkScheduleEntry
UnsubscribeAll()
}
type ShardedTopicFamily interface {
TopicFamily
Subscribe()
}
type DynamicShardedTopicFamily interface {
TopicFamily
TopicsWithMinPeerCount(slot primitives.Slot) map[string]int
TopicsToSubscribeForSlot(slot primitives.Slot) []string
ExtractTopicsForNode(node *enode.Node) ([]string, error)
SubscribeForSlot(slot primitives.Slot)
UnsubscribeForSlot(slot primitives.Slot)
}
type topicFamilyEntry struct {
activationEpoch primitives.Epoch
deactivationEpoch primitives.Epoch
factory func(s *Service, nse params.NetworkScheduleEntry) []TopicFamily
}
func topicFamilySchedule() []topicFamilyEntry {
cfg := params.BeaconConfig()
return []topicFamilyEntry{
// Genesis topic families
{
activationEpoch: cfg.GenesisEpoch,
deactivationEpoch: cfg.FarFutureEpoch,
factory: func(s *Service, nse params.NetworkScheduleEntry) []TopicFamily {
return []TopicFamily{
NewBlockTopicFamily(s, nse),
NewAggregateAndProofTopicFamily(s, nse),
NewVoluntaryExitTopicFamily(s, nse),
NewProposerSlashingTopicFamily(s, nse),
NewAttesterSlashingTopicFamily(s, nse),
NewAttestationTopicFamily(s, nse),
}
},
},
// Altair topic families
{
activationEpoch: cfg.AltairForkEpoch,
deactivationEpoch: cfg.FarFutureEpoch,
factory: func(s *Service, nse params.NetworkScheduleEntry) []TopicFamily {
families := []TopicFamily{
NewSyncContributionAndProofTopicFamily(s, nse),
NewSyncCommitteeTopicFamily(s, nse),
}
if features.Get().EnableLightClient {
families = append(families,
NewLightClientOptimisticUpdateTopicFamily(s, nse),
NewLightClientFinalityUpdateTopicFamily(s, nse),
)
}
return families
},
},
// Capella topic families
{
activationEpoch: cfg.CapellaForkEpoch,
deactivationEpoch: cfg.FarFutureEpoch,
factory: func(s *Service, nse params.NetworkScheduleEntry) []TopicFamily {
return []TopicFamily{NewBlsToExecutionChangeTopicFamily(s, nse)}
},
},
// Blob topic families (static per-subnet) in Deneb and Electra forks (removed in Fulu)
{
activationEpoch: cfg.DenebForkEpoch,
deactivationEpoch: cfg.ElectraForkEpoch,
factory: func(s *Service, nse params.NetworkScheduleEntry) []TopicFamily {
count := cfg.BlobsidecarSubnetCount
families := make([]TopicFamily, 0, count)
for i := range count {
families = append(families, NewBlobTopicFamily(s, nse, i))
}
return families
},
},
{
activationEpoch: cfg.ElectraForkEpoch,
deactivationEpoch: cfg.FuluForkEpoch,
factory: func(s *Service, nse params.NetworkScheduleEntry) []TopicFamily {
count := cfg.BlobsidecarSubnetCountElectra
families := make([]TopicFamily, 0, count)
for i := range count {
families = append(families, NewBlobTopicFamily(s, nse, i))
}
return families
},
},
// Fulu data column topic family
{
activationEpoch: cfg.FuluForkEpoch,
deactivationEpoch: cfg.FarFutureEpoch,
factory: func(s *Service, nse params.NetworkScheduleEntry) []TopicFamily {
return []TopicFamily{NewDataColumnTopicFamily(s, nse)}
},
},
}
}
func TopicFamiliesForEpoch(epoch primitives.Epoch, s *Service, nse params.NetworkScheduleEntry) []TopicFamily {
var activeFamilies []TopicFamily
for _, entry := range topicFamilySchedule() {
if epoch < entry.activationEpoch {
continue
}
if epoch >= entry.deactivationEpoch {
continue
}
activeFamilies = append(activeFamilies, entry.factory(s, nse)...)
}
return activeFamilies
}

View File

@@ -0,0 +1,311 @@
package sync
import (
"context"
"slices"
"testing"
p2ptest "github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/testing"
"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"
)
// createMinimalService creates a minimal Service instance for testing
func createMinimalService(t *testing.T) *Service {
p2pService := p2ptest.NewTestP2P(t)
return &Service{
cfg: &config{
p2p: p2pService,
},
ctx: context.Background(),
}
}
func TestTopicFamiliesForEpoch(t *testing.T) {
// Define test epochs
const (
genesisEpoch = primitives.Epoch(0)
altairEpoch = primitives.Epoch(100)
bellatrixEpoch = primitives.Epoch(200)
capellaEpoch = primitives.Epoch(300)
denebEpoch = primitives.Epoch(400)
electraEpoch = primitives.Epoch(500)
fuluEpoch = primitives.Epoch(600)
)
// Define topic families for each fork
// These names must match what's returned by the Name() method of each topic family
genesisFamilies := []string{
"BlockTopicFamily",
"AggregateAndProofTopicFamily",
"VoluntaryExitTopicFamily",
"ProposerSlashingTopicFamily",
"AttesterSlashingTopicFamily",
"AttestationTopicFamily",
}
altairFamilies := []string{
"SyncContributionAndProofTopicFamily",
"SyncCommitteeTopicFamily",
}
altairLightClientFamilies := []string{
"LightClientOptimisticUpdateTopicFamily",
"LightClientFinalityUpdateTopicFamily",
}
capellaFamilies := []string{
"BlsToExecutionChangeTopicFamily",
}
denebBlobFamilies := []string{
"BlobTopicFamily-0",
"BlobTopicFamily-1",
"BlobTopicFamily-2",
"BlobTopicFamily-3",
"BlobTopicFamily-4",
"BlobTopicFamily-5",
}
electraBlobFamilies := append(slices.Clone(denebBlobFamilies), "BlobTopicFamily-6", "BlobTopicFamily-7")
fuluFamilies := []string{
"DataColumnTopicFamily",
}
// Helper function to combine fork families
combineForks := func(forkSets ...[]string) []string {
var combined []string
for _, forkSet := range forkSets {
combined = append(combined, forkSet...)
}
return combined
}
tests := []struct {
name string
epoch primitives.Epoch
setupConfig func()
enableLightClient bool
expectedFamilies []string
}{
{
name: "epoch before any fork activation should return empty",
epoch: primitives.Epoch(0),
setupConfig: func() {
config := params.BeaconConfig().Copy()
// Set all fork epochs to future epochs
config.GenesisEpoch = primitives.Epoch(1000)
config.AltairForkEpoch = primitives.Epoch(2000)
config.BellatrixForkEpoch = primitives.Epoch(3000)
config.CapellaForkEpoch = primitives.Epoch(4000)
config.DenebForkEpoch = primitives.Epoch(5000)
config.ElectraForkEpoch = primitives.Epoch(6000)
config.FuluForkEpoch = primitives.Epoch(7000)
params.OverrideBeaconConfig(config)
},
expectedFamilies: []string{},
},
{
name: "epoch at genesis should return genesis topic families",
epoch: genesisEpoch,
setupConfig: func() {
config := params.BeaconConfig().Copy()
config.GenesisEpoch = genesisEpoch
config.AltairForkEpoch = altairEpoch
config.BellatrixForkEpoch = bellatrixEpoch
config.CapellaForkEpoch = capellaEpoch
config.DenebForkEpoch = denebEpoch
config.ElectraForkEpoch = electraEpoch
config.FuluForkEpoch = fuluEpoch
params.OverrideBeaconConfig(config)
},
expectedFamilies: genesisFamilies,
},
{
name: "epoch at Altair without light client should have genesis + Altair families",
epoch: altairEpoch,
enableLightClient: false,
setupConfig: func() {
config := params.BeaconConfig().Copy()
config.GenesisEpoch = genesisEpoch
config.AltairForkEpoch = altairEpoch
config.BellatrixForkEpoch = bellatrixEpoch
config.CapellaForkEpoch = capellaEpoch
config.DenebForkEpoch = denebEpoch
config.ElectraForkEpoch = electraEpoch
config.FuluForkEpoch = fuluEpoch
params.OverrideBeaconConfig(config)
},
expectedFamilies: combineForks(genesisFamilies, altairFamilies),
},
{
name: "epoch at Altair with light client enabled should include light client families",
epoch: altairEpoch,
enableLightClient: true,
setupConfig: func() {
config := params.BeaconConfig().Copy()
config.GenesisEpoch = genesisEpoch
config.AltairForkEpoch = altairEpoch
config.BellatrixForkEpoch = bellatrixEpoch
config.CapellaForkEpoch = capellaEpoch
config.DenebForkEpoch = denebEpoch
config.ElectraForkEpoch = electraEpoch
config.FuluForkEpoch = fuluEpoch
params.OverrideBeaconConfig(config)
},
expectedFamilies: combineForks(genesisFamilies, altairFamilies, altairLightClientFamilies),
},
{
name: "epoch at Capella should have genesis + Altair + Capella families",
epoch: capellaEpoch,
setupConfig: func() {
config := params.BeaconConfig().Copy()
config.GenesisEpoch = genesisEpoch
config.AltairForkEpoch = altairEpoch
config.BellatrixForkEpoch = bellatrixEpoch
config.CapellaForkEpoch = capellaEpoch
config.DenebForkEpoch = denebEpoch
config.ElectraForkEpoch = electraEpoch
config.FuluForkEpoch = fuluEpoch
params.OverrideBeaconConfig(config)
},
expectedFamilies: combineForks(genesisFamilies, altairFamilies, capellaFamilies),
},
{
name: "epoch at Deneb should include blob sidecars",
epoch: denebEpoch,
setupConfig: func() {
config := params.BeaconConfig().Copy()
config.GenesisEpoch = genesisEpoch
config.AltairForkEpoch = altairEpoch
config.BellatrixForkEpoch = bellatrixEpoch
config.CapellaForkEpoch = capellaEpoch
config.DenebForkEpoch = denebEpoch
config.ElectraForkEpoch = electraEpoch
config.FuluForkEpoch = fuluEpoch
config.BlobsidecarSubnetCount = 6 // Deneb has 6 blob subnets
params.OverrideBeaconConfig(config)
},
expectedFamilies: combineForks(genesisFamilies, altairFamilies, capellaFamilies, denebBlobFamilies),
},
{
name: "epoch at Electra should have Electra blobs not Deneb blobs",
epoch: electraEpoch,
setupConfig: func() {
config := params.BeaconConfig().Copy()
config.GenesisEpoch = genesisEpoch
config.AltairForkEpoch = altairEpoch
config.BellatrixForkEpoch = bellatrixEpoch
config.CapellaForkEpoch = capellaEpoch
config.DenebForkEpoch = denebEpoch
config.ElectraForkEpoch = electraEpoch
config.FuluForkEpoch = fuluEpoch
config.BlobsidecarSubnetCount = 6
config.BlobsidecarSubnetCountElectra = 8 // Electra has 8 blob subnets
params.OverrideBeaconConfig(config)
},
expectedFamilies: combineForks(genesisFamilies, altairFamilies, capellaFamilies, electraBlobFamilies),
},
{
name: "epoch at Fulu should have data columns not blobs",
epoch: fuluEpoch,
setupConfig: func() {
config := params.BeaconConfig().Copy()
config.GenesisEpoch = genesisEpoch
config.AltairForkEpoch = altairEpoch
config.BellatrixForkEpoch = bellatrixEpoch
config.CapellaForkEpoch = capellaEpoch
config.DenebForkEpoch = denebEpoch
config.ElectraForkEpoch = electraEpoch
config.FuluForkEpoch = fuluEpoch
config.BlobsidecarSubnetCount = 6
config.BlobsidecarSubnetCountElectra = 8
params.OverrideBeaconConfig(config)
},
expectedFamilies: combineForks(genesisFamilies, altairFamilies, capellaFamilies, fuluFamilies),
},
{
name: "epoch after Fulu should maintain Fulu families",
epoch: fuluEpoch + 100,
setupConfig: func() {
config := params.BeaconConfig().Copy()
config.GenesisEpoch = genesisEpoch
config.AltairForkEpoch = altairEpoch
config.BellatrixForkEpoch = bellatrixEpoch
config.CapellaForkEpoch = capellaEpoch
config.DenebForkEpoch = denebEpoch
config.ElectraForkEpoch = electraEpoch
config.FuluForkEpoch = fuluEpoch
config.BlobsidecarSubnetCount = 6
config.BlobsidecarSubnetCountElectra = 8
params.OverrideBeaconConfig(config)
},
expectedFamilies: combineForks(genesisFamilies, altairFamilies, capellaFamilies, fuluFamilies),
},
{
name: "edge case - epoch exactly at deactivation should not include deactivated family",
epoch: electraEpoch, // This deactivates Deneb blobs
setupConfig: func() {
config := params.BeaconConfig().Copy()
config.GenesisEpoch = genesisEpoch
config.AltairForkEpoch = altairEpoch
config.BellatrixForkEpoch = bellatrixEpoch
config.CapellaForkEpoch = capellaEpoch
config.DenebForkEpoch = denebEpoch
config.ElectraForkEpoch = electraEpoch
config.FuluForkEpoch = fuluEpoch
config.BlobsidecarSubnetCount = 6
config.BlobsidecarSubnetCountElectra = 8
params.OverrideBeaconConfig(config)
},
expectedFamilies: combineForks(genesisFamilies, altairFamilies, capellaFamilies, electraBlobFamilies),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params.SetupTestConfigCleanup(t)
if tt.enableLightClient {
resetFlags := features.InitWithReset(&features.Flags{
EnableLightClient: true,
})
defer resetFlags()
}
tt.setupConfig()
service := createMinimalService(t)
families := TopicFamiliesForEpoch(tt.epoch, service, params.NetworkScheduleEntry{})
// Collect actual family names
actualFamilies := make([]string, 0, len(families))
for _, family := range families {
actualFamilies = append(actualFamilies, family.Name())
}
// Assert exact match - families should have exactly the expected families and nothing more
assert.Equal(t, len(tt.expectedFamilies), len(actualFamilies),
"Expected %d families but got %d", len(tt.expectedFamilies), len(actualFamilies))
// Create a map for efficient lookup
expectedMap := make(map[string]bool)
for _, expected := range tt.expectedFamilies {
expectedMap[expected] = true
}
// Check each actual family is expected
for _, actual := range actualFamilies {
if !expectedMap[actual] {
t.Errorf("Unexpected topic family found: %s", actual)
}
delete(expectedMap, actual) // Remove from map as we find it
}
// Check all expected families were found (anything left in map was missing)
for missing := range expectedMap {
t.Errorf("Expected topic family not found: %s", missing)
}
})
}
}

View File

@@ -329,6 +329,8 @@ func TestHandshakeHandlers_Roundtrip(t *testing.T) {
chainStarted: abool.New(),
subHandler: newSubTopicHandler(),
}
r.subscriptionController = NewSubscriptionController(ctx, r)
markInitSyncComplete(t, r)
clock := startup.NewClockSynchronizer()
require.NoError(t, clock.SetClock(startup.NewClock(time.Now(), [32]byte{})))
@@ -945,6 +947,8 @@ func TestStatusRPCRequest_BadPeerHandshake(t *testing.T) {
chainStarted: abool.New(),
subHandler: newSubTopicHandler(),
}
r.subscriptionController = NewSubscriptionController(ctx, r)
markInitSyncComplete(t, r)
clock := startup.NewClockSynchronizer()
require.NoError(t, clock.SetClock(startup.NewClock(time.Now(), [32]byte{})))

View File

@@ -181,7 +181,7 @@ type Service struct {
lcStore *lightClient.Store
dataColumnLogCh chan dataColumnLogEntry
digestActions perDigestSet
subscriptionSpawner func(func()) // see Service.spawn for details
subscriptionController *SubscriptionController
}
// NewService initializes new regular sync service.
@@ -198,6 +198,7 @@ func NewService(ctx context.Context, opts ...Option) *Service {
dataColumnLogCh: make(chan dataColumnLogEntry, 1000),
reconstructionRandGen: rand.NewGenerator(),
}
r.subscriptionController = NewSubscriptionController(ctx, r)
for _, opt := range opts {
if err := opt(r); err != nil {
@@ -232,6 +233,7 @@ func NewService(ctx context.Context, opts ...Option) *Service {
delete(r.seenPendingBlocks, root)
}
})
r.subHandler = newSubTopicHandler()
r.rateLimiter = newRateLimiter(r.cfg.p2p)
r.initCaches()
@@ -323,9 +325,10 @@ func (s *Service) Stop() error {
for _, p := range s.cfg.p2p.Host().Mux().Protocols() {
s.cfg.p2p.Host().RemoveStreamHandler(p)
}
for _, t := range s.cfg.p2p.PubSub().GetTopics() {
s.unSubscribeFromTopic(t)
}
// Stop the gossipsub controller.
s.subscriptionController.Stop()
return nil
}
@@ -405,7 +408,50 @@ func (s *Service) startDiscoveryAndSubscriptions() {
}
// Start the fork watcher.
go s.p2pHandlerControlLoop()
go s.rpcHandlerControlLoop()
// Start the gossipsub controller.
go s.subscriptionController.Start()
for {
if s.cfg.p2p.Started() {
break
}
log.Debug("P2P service not started yet; will retry in 100ms")
select {
case <-s.ctx.Done():
log.WithError(s.ctx.Err()).Error("Context closed while waiting for P2P service to start, exiting startDiscoveryAndSubscriptions routine")
return
case <-time.After(100 * time.Millisecond):
}
}
// Configure the crawler and dialer with the topic extractor / subnet topics
// provider if available.
crawler := s.cfg.p2p.Crawler()
if crawler == nil {
log.Error("No crawler available, topic extraction disabled")
return
}
// Start the crawler now that it has the extractor.
if err := crawler.Start(s.subscriptionController.ExtractTopics); err != nil {
log.WithError(err).Warn("Failed to start peer crawler")
return
}
// Start the gossipsub dialer if available.
if dialer := s.cfg.p2p.GossipDialer(); dialer != nil {
provider := func() map[string]int {
return s.subscriptionController.GetCurrentActiveTopicsWithMinPeerCount()
}
if err := dialer.Start(provider); err != nil {
log.WithError(err).Warn("Failed to start gossip peer dialer")
}
} else {
log.Error("No gossip peer dialer available")
}
}
func (s *Service) writeErrorResponseToStream(responseCode byte, reason string, stream libp2pcore.Stream) {

View File

@@ -2,6 +2,7 @@ package sync
import (
"context"
"fmt"
"sync"
"testing"
"time"
@@ -16,6 +17,7 @@ import (
"github.com/OffchainLabs/prysm/v7/beacon-chain/startup"
state_native "github.com/OffchainLabs/prysm/v7/beacon-chain/state/state-native"
mockSync "github.com/OffchainLabs/prysm/v7/beacon-chain/sync/initial-sync/testing"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
leakybucket "github.com/OffchainLabs/prysm/v7/container/leaky-bucket"
"github.com/OffchainLabs/prysm/v7/crypto/bls"
@@ -67,8 +69,9 @@ func TestSyncHandlers_WaitToSync(t *testing.T) {
chainStarted: abool.New(),
clockWaiter: gs,
}
r.subscriptionController = NewSubscriptionController(t.Context(), &r)
topic := "/eth2/%x/beacon_block"
topicFmt := "/eth2/%x/beacon_block"
go r.startDiscoveryAndSubscriptions()
time.Sleep(100 * time.Millisecond)
@@ -82,7 +85,10 @@ func TestSyncHandlers_WaitToSync(t *testing.T) {
msg := util.NewBeaconBlock()
msg.Block.ParentRoot = util.Random32Bytes(t)
msg.Signature = sk.Sign([]byte("data")).Marshal()
p2p.ReceivePubSub(topic, msg)
// Build full topic using current fork digest
nse := params.GetNetworkScheduleEntry(r.cfg.clock.CurrentEpoch())
fullTopic := fmt.Sprintf(topicFmt, nse.ForkDigest) + p2p.Encoding().ProtocolSuffix()
p2p.ReceivePubSub(fullTopic, msg)
// wait for chainstart to be sent
time.Sleep(400 * time.Millisecond)
require.Equal(t, true, r.chainStarted.IsSet(), "Did not receive chain start event.")
@@ -137,6 +143,7 @@ func TestSyncHandlers_WaitTillSynced(t *testing.T) {
clockWaiter: gs,
initialSyncComplete: make(chan struct{}),
}
r.subscriptionController = NewSubscriptionController(t.Context(), &r)
r.initCaches()
var vr [32]byte
@@ -169,14 +176,16 @@ func TestSyncHandlers_WaitTillSynced(t *testing.T) {
// Save block into DB so that validateBeaconBlockPubSub() process gets short cut.
util.SaveBlock(t, ctx, r.cfg.beaconDB, msg)
topic := "/eth2/%x/beacon_block"
p2p.ReceivePubSub(topic, msg)
topicFmt := "/eth2/%x/beacon_block"
nse := params.GetNetworkScheduleEntry(r.cfg.clock.CurrentEpoch())
fullTopic := fmt.Sprintf(topicFmt, nse.ForkDigest) + p2p.Encoding().ProtocolSuffix()
p2p.ReceivePubSub(fullTopic, msg)
assert.Equal(t, 0, len(blockChan), "block was received by sync service despite not being fully synced")
close(r.initialSyncComplete)
<-syncCompleteCh
p2p.ReceivePubSub(topic, msg)
p2p.ReceivePubSub(fullTopic, msg)
select {
case <-blockChan:
@@ -206,6 +215,7 @@ func TestSyncService_StopCleanly(t *testing.T) {
clockWaiter: gs,
initialSyncComplete: make(chan struct{}),
}
r.subscriptionController = NewSubscriptionController(t.Context(), &r)
markInitSyncComplete(t, &r)
go r.startDiscoveryAndSubscriptions()
@@ -252,7 +262,7 @@ func TestService_Stop_SendsGoodbyeMessages(t *testing.T) {
// Create service with connected peers
d := dbTest.SetupDB(t)
chain := &mockChain.ChainService{Genesis: time.Now(), ValidatorsRoot: [32]byte{}}
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(t.Context())
r := &Service{
cfg: &config{
@@ -265,6 +275,7 @@ func TestService_Stop_SendsGoodbyeMessages(t *testing.T) {
cancel: cancel,
rateLimiter: newRateLimiter(p1),
}
r.subscriptionController = NewSubscriptionController(ctx, r)
// Initialize context map for RPC
ctxMap, err := ContextByteVersionsForValRoot(chain.ValidatorsRoot)
@@ -330,7 +341,7 @@ func TestService_Stop_TimeoutHandling(t *testing.T) {
d := dbTest.SetupDB(t)
chain := &mockChain.ChainService{Genesis: time.Now(), ValidatorsRoot: [32]byte{}}
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(t.Context())
r := &Service{
cfg: &config{
@@ -343,6 +354,7 @@ func TestService_Stop_TimeoutHandling(t *testing.T) {
cancel: cancel,
rateLimiter: newRateLimiter(p1),
}
r.subscriptionController = NewSubscriptionController(ctx, r)
// Initialize context map for RPC
ctxMap, err := ContextByteVersionsForValRoot(chain.ValidatorsRoot)
@@ -391,7 +403,7 @@ func TestService_Stop_ConcurrentGoodbyeMessages(t *testing.T) {
d := dbTest.SetupDB(t)
chain := &mockChain.ChainService{Genesis: time.Now(), ValidatorsRoot: [32]byte{}}
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(t.Context())
r := &Service{
cfg: &config{
@@ -404,6 +416,7 @@ func TestService_Stop_ConcurrentGoodbyeMessages(t *testing.T) {
cancel: cancel,
rateLimiter: newRateLimiter(p1),
}
r.subscriptionController = NewSubscriptionController(ctx, r)
// Initialize context map for RPC
ctxMap, err := ContextByteVersionsForValRoot(chain.ValidatorsRoot)

View File

@@ -4,9 +4,7 @@ import (
"context"
"fmt"
"reflect"
"runtime/debug"
"strings"
"sync"
"time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/cache"
@@ -20,8 +18,6 @@ import (
"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"
"github.com/OffchainLabs/prysm/v7/monitoring/tracing/trace"
ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1"
"github.com/OffchainLabs/prysm/v7/runtime/messagehandler"
"github.com/OffchainLabs/prysm/v7/time/slots"
@@ -31,124 +27,12 @@ import (
"github.com/libp2p/go-libp2p/core/peer"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"google.golang.org/protobuf/proto"
)
const pubsubMessageTimeout = 30 * time.Second
var errInvalidDigest = errors.New("invalid digest")
// wrappedVal represents a gossip validator which also returns an error along with the result.
type wrappedVal func(context.Context, peer.ID, *pubsub.Message) (pubsub.ValidationResult, error)
// subHandler represents handler for a given subscription.
type subHandler func(context.Context, proto.Message) error
// noopHandler is used for subscriptions that do not require anything to be done.
var noopHandler subHandler = func(ctx context.Context, msg proto.Message) error {
return nil
}
// subscribeParameters holds the parameters that are needed to construct a set of subscriptions topics for a given
// set of gossipsub subnets.
type subscribeParameters struct {
topicFormat string
validate wrappedVal
handle subHandler
nse params.NetworkScheduleEntry
// getSubnetsToJoin is a function that returns all subnets the node should join.
getSubnetsToJoin func(currentSlot primitives.Slot) map[uint64]bool
// getSubnetsRequiringPeers is a function that returns all subnets that require peers to be found
// but for which no subscriptions are needed.
getSubnetsRequiringPeers func(currentSlot primitives.Slot) map[uint64]bool
}
// shortTopic is a less verbose version of topic strings used for logging.
func (p subscribeParameters) shortTopic() string {
short := p.topicFormat
fmtLen := len(short)
if fmtLen >= 3 && short[fmtLen-3:] == "_%d" {
short = short[:fmtLen-3]
}
return fmt.Sprintf(short, p.nse.ForkDigest)
}
func (p subscribeParameters) logFields() logrus.Fields {
return logrus.Fields{
"topic": p.shortTopic(),
}
}
// fullTopic is the fully qualified topic string, given to gossipsub.
func (p subscribeParameters) fullTopic(subnet uint64, suffix string) string {
return fmt.Sprintf(p.topicFormat, p.nse.ForkDigest, subnet) + suffix
}
// subnetTracker keeps track of which subnets we are subscribed to, out of the set of
// possible subnets described by a `subscribeParameters`.
type subnetTracker struct {
subscribeParameters
mu sync.RWMutex
subscriptions map[uint64]*pubsub.Subscription
}
func newSubnetTracker(p subscribeParameters) *subnetTracker {
return &subnetTracker{
subscribeParameters: p,
subscriptions: make(map[uint64]*pubsub.Subscription),
}
}
// unwanted takes a list of wanted subnets and returns a list of currently subscribed subnets that are not included.
func (t *subnetTracker) unwanted(wanted map[uint64]bool) []uint64 {
t.mu.RLock()
defer t.mu.RUnlock()
unwanted := make([]uint64, 0, len(t.subscriptions))
for subnet := range t.subscriptions {
if wanted == nil || !wanted[subnet] {
unwanted = append(unwanted, subnet)
}
}
return unwanted
}
// missing takes a list of wanted subnets and returns a list of wanted subnets that are not currently tracked.
func (t *subnetTracker) missing(wanted map[uint64]bool) []uint64 {
t.mu.RLock()
defer t.mu.RUnlock()
missing := make([]uint64, 0, len(wanted))
for subnet := range wanted {
if _, ok := t.subscriptions[subnet]; !ok {
missing = append(missing, subnet)
}
}
return missing
}
// cancelSubscription cancels and removes the subscription for a given subnet.
func (t *subnetTracker) cancelSubscription(subnet uint64) {
t.mu.Lock()
defer t.mu.Unlock()
defer delete(t.subscriptions, subnet)
sub := t.subscriptions[subnet]
if sub == nil {
return
}
sub.Cancel()
}
// track asks subscriptionTracker to hold on to the subscription for a given subnet so
// that we can remember that it is tracked and cancel its context when it's time to unsubscribe.
func (t *subnetTracker) track(subnet uint64, sub *pubsub.Subscription) {
if sub == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.subscriptions[subnet] = sub
}
// noopValidator is a no-op that only decodes the message, but does not check its contents.
func (s *Service) noopValidator(_ context.Context, _ peer.ID, msg *pubsub.Message) (pubsub.ValidationResult, error) {
m, err := s.decodePubsubMessage(msg)
@@ -192,272 +76,6 @@ func (s *Service) activeSyncSubnetIndices(currentSlot primitives.Slot) map[uint6
return mapFromSlice(subscriptions)
}
// spawn allows the Service to use a custom function for launching goroutines.
// This is useful in tests where we can set spawner to a sync.WaitGroup and
// wait for the spawned goroutines to finish.
func (s *Service) spawn(f func()) {
if s.subscriptionSpawner != nil {
s.subscriptionSpawner(f)
} else {
go f()
}
}
// Register PubSub subscribers
func (s *Service) registerSubscribers(nse params.NetworkScheduleEntry) bool {
// If we have already registered for this fork digest, exit early.
if s.digestActionDone(nse.ForkDigest, registerGossipOnce) {
return false
}
s.spawn(func() {
s.subscribe(p2p.BlockSubnetTopicFormat, s.validateBeaconBlockPubSub, s.beaconBlockSubscriber, nse)
})
s.spawn(func() {
s.subscribe(p2p.AggregateAndProofSubnetTopicFormat, s.validateAggregateAndProof, s.beaconAggregateProofSubscriber, nse)
})
s.spawn(func() {
s.subscribe(p2p.ExitSubnetTopicFormat, s.validateVoluntaryExit, s.voluntaryExitSubscriber, nse)
})
s.spawn(func() {
s.subscribe(p2p.ProposerSlashingSubnetTopicFormat, s.validateProposerSlashing, s.proposerSlashingSubscriber, nse)
})
s.spawn(func() {
s.subscribe(p2p.AttesterSlashingSubnetTopicFormat, s.validateAttesterSlashing, s.attesterSlashingSubscriber, nse)
})
s.spawn(func() {
s.subscribeWithParameters(subscribeParameters{
topicFormat: p2p.AttestationSubnetTopicFormat,
validate: s.validateCommitteeIndexBeaconAttestation,
handle: s.committeeIndexBeaconAttestationSubscriber,
getSubnetsToJoin: s.persistentAndAggregatorSubnetIndices,
getSubnetsRequiringPeers: attesterSubnetIndices,
nse: nse,
})
})
// New gossip topic in Altair
if params.BeaconConfig().AltairForkEpoch <= nse.Epoch {
s.spawn(func() {
s.subscribe(
p2p.SyncContributionAndProofSubnetTopicFormat,
s.validateSyncContributionAndProof,
s.syncContributionAndProofSubscriber,
nse,
)
})
s.spawn(func() {
s.subscribeWithParameters(subscribeParameters{
topicFormat: p2p.SyncCommitteeSubnetTopicFormat,
validate: s.validateSyncCommitteeMessage,
handle: s.syncCommitteeMessageSubscriber,
getSubnetsToJoin: s.activeSyncSubnetIndices,
nse: nse,
})
})
if features.Get().EnableLightClient {
s.spawn(func() {
s.subscribe(
p2p.LightClientOptimisticUpdateTopicFormat,
s.validateLightClientOptimisticUpdate,
noopHandler,
nse,
)
})
s.spawn(func() {
s.subscribe(
p2p.LightClientFinalityUpdateTopicFormat,
s.validateLightClientFinalityUpdate,
noopHandler,
nse,
)
})
}
}
// New gossip topic in Capella
if params.BeaconConfig().CapellaForkEpoch <= nse.Epoch {
s.spawn(func() {
s.subscribe(
p2p.BlsToExecutionChangeSubnetTopicFormat,
s.validateBlsToExecutionChange,
s.blsToExecutionChangeSubscriber,
nse,
)
})
}
// New gossip topic in Deneb, removed in Electra
if params.BeaconConfig().DenebForkEpoch <= nse.Epoch && nse.Epoch < params.BeaconConfig().ElectraForkEpoch {
s.spawn(func() {
s.subscribeWithParameters(subscribeParameters{
topicFormat: p2p.BlobSubnetTopicFormat,
validate: s.validateBlob,
handle: s.blobSubscriber,
nse: nse,
getSubnetsToJoin: func(primitives.Slot) map[uint64]bool {
return mapFromCount(params.BeaconConfig().BlobsidecarSubnetCount)
},
})
})
}
// New gossip topic in Electra, removed in Fulu
if params.BeaconConfig().ElectraForkEpoch <= nse.Epoch && nse.Epoch < params.BeaconConfig().FuluForkEpoch {
s.spawn(func() {
s.subscribeWithParameters(subscribeParameters{
topicFormat: p2p.BlobSubnetTopicFormat,
validate: s.validateBlob,
handle: s.blobSubscriber,
nse: nse,
getSubnetsToJoin: func(currentSlot primitives.Slot) map[uint64]bool {
return mapFromCount(params.BeaconConfig().BlobsidecarSubnetCountElectra)
},
})
})
}
// New gossip topic in Fulu.
if params.BeaconConfig().FuluForkEpoch <= nse.Epoch {
s.spawn(func() {
s.subscribeWithParameters(subscribeParameters{
topicFormat: p2p.DataColumnSubnetTopicFormat,
validate: s.validateDataColumn,
handle: s.dataColumnSubscriber,
nse: nse,
getSubnetsToJoin: s.dataColumnSubnetIndices,
getSubnetsRequiringPeers: s.allDataColumnSubnets,
})
})
}
return true
}
func (s *Service) subscriptionRequestExpired(nse params.NetworkScheduleEntry) bool {
next := params.NextNetworkScheduleEntry(nse.Epoch)
return next.Epoch != nse.Epoch && s.cfg.clock.CurrentEpoch() > next.Epoch
}
func (s *Service) subscribeLogFields(topic string, nse params.NetworkScheduleEntry) logrus.Fields {
return logrus.Fields{
"topic": topic,
"digest": nse.ForkDigest,
"forkEpoch": nse.Epoch,
"currentEpoch": s.cfg.clock.CurrentEpoch(),
}
}
// subscribe to a given topic with a given validator and subscription handler.
// The base protobuf message is used to initialize new messages for decoding.
func (s *Service) subscribe(topic string, validator wrappedVal, handle subHandler, nse params.NetworkScheduleEntry) {
if err := s.waitForInitialSync(s.ctx); err != nil {
log.WithFields(s.subscribeLogFields(topic, nse)).WithError(err).Debug("Context cancelled while waiting for initial sync, not subscribing to topic")
return
}
// Check if this subscribe request is still valid - we may have crossed another fork epoch while waiting for initial sync.
if s.subscriptionRequestExpired(nse) {
// If we are already past the next fork epoch, do not subscribe to this topic.
log.WithFields(s.subscribeLogFields(topic, nse)).Debug("Not subscribing to topic as we are already past the next fork epoch")
return
}
base := p2p.GossipTopicMappings(topic, nse.Epoch)
if base == nil {
// Impossible condition as it would mean topic does not exist.
panic(fmt.Sprintf("%s is not mapped to any message in GossipTopicMappings", topic)) // lint:nopanic -- Impossible condition.
}
s.subscribeWithBase(s.addDigestToTopic(topic, nse.ForkDigest), validator, handle)
}
func (s *Service) subscribeWithBase(topic string, validator wrappedVal, handle subHandler) *pubsub.Subscription {
topic += s.cfg.p2p.Encoding().ProtocolSuffix()
log := log.WithField("topic", topic)
// Do not resubscribe already seen subscriptions.
ok := s.subHandler.topicExists(topic)
if ok {
log.WithField("topic", topic).Error("Provided topic already has an active subscription running")
return nil
}
if err := s.cfg.p2p.PubSub().RegisterTopicValidator(s.wrapAndReportValidation(topic, validator)); err != nil {
log.WithError(err).Error("Could not register validator for topic")
return nil
}
sub, err := s.cfg.p2p.SubscribeToTopic(topic)
if err != nil {
// Any error subscribing to a PubSub topic would be the result of a misconfiguration of
// libp2p PubSub library or a subscription request to a topic that fails to match the topic
// subscription filter.
log.WithError(err).Error("Could not subscribe topic")
return nil
}
s.subHandler.addTopic(sub.Topic(), sub)
// Pipeline decodes the incoming subscription data, runs the validation, and handles the
// message.
pipeline := func(msg *pubsub.Message) {
ctx, cancel := context.WithTimeout(s.ctx, pubsubMessageTimeout)
defer cancel()
ctx, span := trace.StartSpan(ctx, "sync.pubsub")
defer span.End()
defer func() {
if r := recover(); r != nil {
tracing.AnnotateError(span, fmt.Errorf("panic occurred: %v", r))
log.WithField("error", r).
WithField("recoveredAt", "subscribeWithBase").
WithField("stack", string(debug.Stack())).
Error("Panic occurred")
}
}()
span.SetAttributes(trace.StringAttribute("topic", topic))
if msg.ValidatorData == nil {
log.Error("Received nil message on pubsub")
messageFailedProcessingCounter.WithLabelValues(topic).Inc()
return
}
if err := handle(ctx, msg.ValidatorData.(proto.Message)); err != nil {
tracing.AnnotateError(span, err)
log.WithError(err).Error("Could not handle p2p pubsub")
messageFailedProcessingCounter.WithLabelValues(topic).Inc()
return
}
}
// The main message loop for receiving incoming messages from this subscription.
messageLoop := func() {
for {
msg, err := sub.Next(s.ctx)
if err != nil {
// This should only happen when the context is cancelled or subscription is cancelled.
if !errors.Is(err, pubsub.ErrSubscriptionCancelled) { // Only log a warning on unexpected errors.
log.WithError(err).Warn("Subscription next failed")
}
// Cancel subscription in the event of an error, as we are
// now exiting topic event loop.
sub.Cancel()
return
}
if msg.ReceivedFrom == s.cfg.p2p.PeerID() {
continue
}
go pipeline(msg)
}
}
go messageLoop()
log.WithField("topic", topic).Info("Subscribed to")
return sub
}
// Wrap the pubsub validator with a metric monitoring function. This function increments the
// appropriate counter if the particular message fails to validate.
func (s *Service) wrapAndReportValidation(topic string, v wrappedVal) (string, pubsub.ValidatorEx) {
@@ -527,151 +145,6 @@ func (s *Service) wrapAndReportValidation(topic string, v wrappedVal) (string, p
}
}
// pruneNotWanted unsubscribes from topics we are currently subscribed to but that are
// not in the list of wanted subnets.
func (s *Service) pruneNotWanted(t *subnetTracker, wantedSubnets map[uint64]bool) {
for _, subnet := range t.unwanted(wantedSubnets) {
t.cancelSubscription(subnet)
s.unSubscribeFromTopic(t.fullTopic(subnet, s.cfg.p2p.Encoding().ProtocolSuffix()))
}
}
// subscribeWithParameters subscribes to a list of subnets.
func (s *Service) subscribeWithParameters(p subscribeParameters) {
ctx, cancel := context.WithCancel(s.ctx)
defer cancel()
tracker := newSubnetTracker(p)
go s.ensurePeers(ctx, tracker)
go s.logMinimumPeersPerSubnet(ctx, p)
if err := s.waitForInitialSync(ctx); err != nil {
log.WithFields(p.logFields()).WithError(err).Debug("Could not subscribe to subnets as initial sync failed")
return
}
s.trySubscribeSubnets(tracker)
slotTicker := slots.NewSlotTicker(s.cfg.clock.GenesisTime(), params.BeaconConfig().SecondsPerSlot)
defer slotTicker.Done()
for {
select {
case <-slotTicker.C():
// Check if this subscribe request is still valid - we may have crossed another fork epoch while waiting for initial sync.
if s.subscriptionRequestExpired(p.nse) {
// If we are already past the next fork epoch, do not subscribe to this topic.
log.WithFields(logrus.Fields{
"topic": p.shortTopic(),
"digest": p.nse.ForkDigest,
"epoch": p.nse.Epoch,
"currentEpoch": s.cfg.clock.CurrentEpoch(),
}).Debug("Exiting topic subnet subscription loop")
return
}
s.trySubscribeSubnets(tracker)
case <-s.ctx.Done():
return
}
}
}
// trySubscribeSubnets attempts to subscribe to any missing subnets that we should be subscribed to.
// Only if initial sync is complete.
func (s *Service) trySubscribeSubnets(t *subnetTracker) {
subnetsToJoin := t.getSubnetsToJoin(s.cfg.clock.CurrentSlot())
s.pruneNotWanted(t, subnetsToJoin)
for _, subnet := range t.missing(subnetsToJoin) {
// TODO: subscribeWithBase appends the protocol suffix, other methods don't. Make this consistent.
topic := t.fullTopic(subnet, "")
t.track(subnet, s.subscribeWithBase(topic, t.validate, t.handle))
}
}
func (s *Service) ensurePeers(ctx context.Context, tracker *subnetTracker) {
// Try once immediately so we don't have to wait until the next slot.
s.tryEnsurePeers(ctx, tracker)
oncePerSlot := slots.NewSlotTicker(s.cfg.clock.GenesisTime(), params.BeaconConfig().SecondsPerSlot)
defer oncePerSlot.Done()
for {
select {
case <-oncePerSlot.C():
s.tryEnsurePeers(ctx, tracker)
case <-ctx.Done():
return
}
}
}
func (s *Service) tryEnsurePeers(ctx context.Context, tracker *subnetTracker) {
timeout := (time.Duration(params.BeaconConfig().SecondsPerSlot) * time.Second) - 100*time.Millisecond
minPeers := flags.Get().MinimumPeersPerSubnet
neededSubnets := computeAllNeededSubnets(s.cfg.clock.CurrentSlot(), tracker.getSubnetsToJoin, tracker.getSubnetsRequiringPeers)
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
err := s.cfg.p2p.FindAndDialPeersWithSubnets(ctx, tracker.topicFormat, tracker.nse.ForkDigest, minPeers, neededSubnets)
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
log.WithFields(tracker.logFields()).WithError(err).Debug("Could not find peers with subnets")
}
}
func (s *Service) logMinimumPeersPerSubnet(ctx context.Context, p subscribeParameters) {
logFields := p.logFields()
minimumPeersPerSubnet := flags.Get().MinimumPeersPerSubnet
// Warn the user if we are not subscribed to enough peers in the subnets.
log := log.WithField("minimum", minimumPeersPerSubnet)
logTicker := time.NewTicker(5 * time.Minute)
defer logTicker.Stop()
for {
select {
case <-logTicker.C:
currentSlot := s.cfg.clock.CurrentSlot()
subnetsToFindPeersIndex := computeAllNeededSubnets(currentSlot, p.getSubnetsToJoin, p.getSubnetsRequiringPeers)
isSubnetWithMissingPeers := false
// Find new peers for wanted subnets if needed.
for index := range subnetsToFindPeersIndex {
topic := fmt.Sprintf(p.topicFormat, p.nse.ForkDigest, index)
// Check if we have enough peers in the subnet. Skip if we do.
if count := s.connectedPeersCount(topic); count < minimumPeersPerSubnet {
isSubnetWithMissingPeers = true
log.WithFields(logrus.Fields{
"topic": topic,
"actual": count,
}).Debug("Not enough connected peers")
}
}
if !isSubnetWithMissingPeers {
log.WithFields(logFields).Debug("All subnets have enough connected peers")
}
case <-ctx.Done():
return
}
}
}
func (s *Service) unSubscribeFromTopic(topic string) {
log.WithField("topic", topic).Info("Unsubscribed from")
if err := s.cfg.p2p.PubSub().UnregisterTopicValidator(topic); err != nil {
log.WithError(err).Error("Could not unregister topic validator")
}
sub := s.subHandler.subForTopic(topic)
if sub != nil {
sub.Cancel()
}
s.subHandler.removeTopic(topic)
if err := s.cfg.p2p.LeaveTopic(topic); err != nil {
log.WithError(err).Error("Unable to leave topic")
}
}
// connectedPeersCount counts how many peer for a given topic are connected to the node.
func (s *Service) connectedPeersCount(subnetTopic string) int {
topic := subnetTopic + s.cfg.p2p.Encoding().ProtocolSuffix()
peersWithSubnet := s.cfg.p2p.PubSub().ListPeers(topic)
return len(peersWithSubnet)
}
func (s *Service) dataColumnSubnetIndices(primitives.Slot) map[uint64]bool {
nodeID := s.cfg.p2p.NodeID()
@@ -772,6 +245,15 @@ func (s *Service) filterNeededPeers(pids []peer.ID) []peer.ID {
}
}
dialer := s.cfg.p2p.GossipDialer()
if dialer != nil {
// ask the dialer for peers that should be protected from pruning.
for _, pid := range dialer.ProtectedPeers() {
peerMap[pid] = true
}
}
// Clear out necessary peers from the peers to prune.
newPeers := make([]peer.ID, 0, len(pids))
@@ -816,34 +298,6 @@ func isDigestValid(digest [4]byte, clock *startup.Clock) (bool, error) {
return params.ForkDigest(current) == digest, nil
}
// computeAllNeededSubnets computes the subnets we want to join
// and the subnets for which we want to find peers.
func computeAllNeededSubnets(
currentSlot primitives.Slot,
getSubnetsToJoin func(currentSlot primitives.Slot) map[uint64]bool,
getSubnetsRequiringPeers func(currentSlot primitives.Slot) map[uint64]bool,
) map[uint64]bool {
// Retrieve the subnets we want to join.
subnetsToJoin := getSubnetsToJoin(currentSlot)
// Retrieve the subnets we want to find peers into.
subnetsRequiringPeers := make(map[uint64]bool)
if getSubnetsRequiringPeers != nil {
subnetsRequiringPeers = getSubnetsRequiringPeers(currentSlot)
}
// Combine the two maps to get all needed subnets.
neededSubnets := make(map[uint64]bool, len(subnetsToJoin)+len(subnetsRequiringPeers))
for subnet := range subnetsToJoin {
neededSubnets[subnet] = true
}
for subnet := range subnetsRequiringPeers {
neededSubnets[subnet] = true
}
return neededSubnets
}
func agentString(pid peer.ID, hst host.Host) string {
rawVersion, storeErr := hst.Peerstore().Get(pid, "AgentVersion")
agString, ok := rawVersion.(string)

View File

@@ -0,0 +1,199 @@
package sync
import (
"context"
"fmt"
"sync"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/time/slots"
"github.com/ethereum/go-ethereum/p2p/enode"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
type topicFamilyKey struct {
topicName string
forkDigest [4]byte
}
func topicFamilyKeyFrom(tf TopicFamily) topicFamilyKey {
return topicFamilyKey{topicName: tf.Name(), forkDigest: tf.NetworkScheduleEntry().ForkDigest}
}
type SubscriptionController struct {
ctx context.Context
cancel context.CancelFunc
syncService *Service
wg sync.WaitGroup
mu sync.RWMutex
activeTopicFamilies map[topicFamilyKey]TopicFamily
}
func NewSubscriptionController(ctx context.Context, s *Service) *SubscriptionController {
ctx, cancel := context.WithCancel(ctx)
return &SubscriptionController{
ctx: ctx,
cancel: cancel,
syncService: s,
activeTopicFamilies: make(map[topicFamilyKey]TopicFamily),
}
}
func (g *SubscriptionController) Start() {
currentEpoch := g.syncService.cfg.clock.CurrentEpoch()
if err := g.syncService.waitForInitialSync(g.ctx); err != nil {
log.WithError(err).Debug("Context cancelled while waiting for initial sync, not starting SubscriptionController")
return
}
g.updateActiveTopicFamilies(currentEpoch)
g.wg.Go(func() { g.controlLoop() })
log.Info("SubscriptionController started")
}
func (g *SubscriptionController) controlLoop() {
slotTicker := slots.NewSlotTicker(g.syncService.cfg.clock.GenesisTime(), params.BeaconConfig().SecondsPerSlot)
defer slotTicker.Done()
for {
select {
case <-slotTicker.C():
currentEpoch := g.syncService.cfg.clock.CurrentEpoch()
g.updateActiveTopicFamilies(currentEpoch)
case <-g.ctx.Done():
return
}
}
}
func (g *SubscriptionController) updateActiveTopicFamilies(currentEpoch primitives.Epoch) {
slot := g.syncService.cfg.clock.CurrentSlot()
currentNSE := params.GetNetworkScheduleEntry(currentEpoch)
families := TopicFamiliesForEpoch(currentEpoch, g.syncService, currentNSE)
// also subscribe to topics for the next epoch if there is a fork in the next epoch
nextNSE := params.GetNetworkScheduleEntry(currentEpoch + 1)
if currentNSE.Epoch != nextNSE.Epoch {
families = append(families, TopicFamiliesForEpoch(nextNSE.Epoch, g.syncService, nextNSE)...)
}
g.mu.Lock()
defer g.mu.Unlock()
// register topic families for the current NSE -> this is idempotent
for _, family := range families {
key := topicFamilyKeyFrom(family)
existing, seen := g.activeTopicFamilies[key]
if !seen {
g.activeTopicFamilies[key] = family
existing = family
}
switch tf := existing.(type) {
case DynamicShardedTopicFamily:
tf.UnsubscribeForSlot(slot)
tf.SubscribeForSlot(slot)
case ShardedTopicFamily:
if !seen {
tf.Subscribe()
}
}
}
// If we are still in our genesis fork version then exit early.
if currentNSE.Epoch == params.BeaconConfig().GenesisEpoch {
return
}
if currentEpoch < currentNSE.Epoch+1 {
return
}
previous := params.GetNetworkScheduleEntry(currentNSE.Epoch - 1)
// remove topic families for the previous NSE -> this is idempotent
for key, family := range g.activeTopicFamilies {
if key.forkDigest == previous.ForkDigest {
family.UnsubscribeAll()
delete(g.activeTopicFamilies, key)
log.WithFields(logrus.Fields{
"topicName": key.topicName,
"forkDigest": fmt.Sprintf("%#x", key.forkDigest),
}).Info("Removed topic family")
}
}
}
func (g *SubscriptionController) Stop() {
g.cancel()
g.wg.Wait()
g.mu.Lock()
defer g.mu.Unlock()
for _, family := range g.activeTopicFamilies {
family.UnsubscribeAll()
}
}
func (g *SubscriptionController) GetCurrentActiveTopicsWithMinPeerCount() map[string]int {
g.mu.RLock()
defer g.mu.RUnlock()
slot := g.syncService.cfg.clock.CurrentSlot()
topics := make(map[string]int)
for _, f := range g.activeTopicFamilies {
tfm, ok := f.(DynamicShardedTopicFamily)
if !ok {
continue
}
for topic, count := range tfm.TopicsWithMinPeerCount(slot) {
topics[topic] += count
}
}
return topics
}
func (g *SubscriptionController) ExtractTopics(_ context.Context, node *enode.Node) ([]string, error) {
if node == nil {
return nil, errors.New("enode is nil")
}
g.mu.RLock()
defer g.mu.RUnlock()
families := make([]DynamicShardedTopicFamily, 0, len(g.activeTopicFamilies))
for _, f := range g.activeTopicFamilies {
if tfm, ok := f.(DynamicShardedTopicFamily); ok {
families = append(families, tfm)
}
}
// Collect topics from dynamic families only, de-duplicated.
topicSet := make(map[string]struct{})
for _, df := range families {
topics, err := df.ExtractTopicsForNode(node)
if err != nil {
log.WithError(err).WithFields(logrus.Fields{
"topicFamily": fmt.Sprintf("%T", df),
}).Debug("Failed to get topics for node from family")
continue
}
for _, t := range topics {
topicSet[t] = struct{}{}
}
}
out := make([]string, 0, len(topicSet))
for t := range topicSet {
out = append(out, t)
}
return out, nil
}

View File

@@ -0,0 +1,545 @@
package sync
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/OffchainLabs/prysm/v7/async/abool"
mockChain "github.com/OffchainLabs/prysm/v7/beacon-chain/blockchain/testing"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p"
p2ptest "github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/testing"
mockSync "github.com/OffchainLabs/prysm/v7/beacon-chain/sync/initial-sync/testing"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/genesis"
"github.com/OffchainLabs/prysm/v7/testing/assert"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/ethereum/go-ethereum/p2p/enode"
)
var _ DynamicShardedTopicFamily = (*testDynFamly)(nil)
// testDynFamly is a test implementation of a dynamic-subnet topic family.
type testDynFamly struct {
baseTopicFamily
topics []string
name string
topicsWithMinPeers map[string]int
}
func (f *testDynFamly) Name() string {
return f.name
}
func (f *testDynFamly) Validator() wrappedVal {
return nil
}
func (f *testDynFamly) Handler() subHandler {
return noopHandler
}
func (f *testDynFamly) GetFullTopicString(subnet uint64) string {
return fmt.Sprintf("topic-%d", subnet)
}
func (f *testDynFamly) TopicsToSubscribeForSlot(_ primitives.Slot) []string {
return f.topics
}
func (f *testDynFamly) ExtractTopicsForNode(_ *enode.Node) ([]string, error) {
return f.topics, nil
}
func (f *testDynFamly) SubscribeForSlot(_ primitives.Slot) {
f.baseTopicFamily.subscribeToTopics(f.topics)
}
func (f *testDynFamly) UnsubscribeForSlot(_ primitives.Slot) {}
func (f *testDynFamly) TopicsWithMinPeerCount(_ primitives.Slot) map[string]int {
return f.topicsWithMinPeers
}
type staticTopicFamily struct {
*baseTopicFamily
name string
topics []string
}
func (f *staticTopicFamily) Name() string {
return f.name
}
func (f *staticTopicFamily) Validator() wrappedVal {
return f.validator
}
func (f *staticTopicFamily) Handler() subHandler {
return f.handler
}
func (f *staticTopicFamily) Subscribe() {
f.baseTopicFamily.subscribeToTopics(f.topics)
}
func testSubscriptionControllerService(t *testing.T, current primitives.Epoch) *Service {
closedChan := make(chan struct{})
close(closedChan)
peer2peer := p2ptest.NewTestP2P(t)
chainService := &mockChain.ChainService{
Genesis: genesis.Time(),
ValidatorsRoot: genesis.ValidatorsRoot(),
}
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
r := &Service{
ctx: ctx,
cancel: cancel,
cfg: &config{
p2p: peer2peer,
chain: chainService,
clock: defaultClockWithTimeAtEpoch(current),
initialSync: &mockSync.Sync{IsSyncing: false},
},
chainStarted: abool.New(),
subHandler: newSubTopicHandler(),
initialSyncComplete: closedChan,
}
r.subscriptionController = NewSubscriptionController(context.Background(), r)
return r
}
func TestSubscriptionController_CheckForNextEpochForkSubscriptions(t *testing.T) {
closedChan := make(chan struct{})
close(closedChan)
params.SetupTestConfigCleanup(t)
genesis.StoreEmbeddedDuringTest(t, params.BeaconConfig().ConfigName)
params.BeaconConfig().FuluForkEpoch = params.BeaconConfig().ElectraForkEpoch + 4096*2
params.BeaconConfig().InitializeForkSchedule()
cfg := params.BeaconConfig()
altairForkEpoch := cfg.AltairForkEpoch
bellatrixForkEpoch := cfg.BellatrixForkEpoch
capellaForkEpoch := cfg.CapellaForkEpoch
denebForkEpoch := cfg.DenebForkEpoch
electraForkEpoch := cfg.ElectraForkEpoch
fuluForkEpoch := cfg.FuluForkEpoch
blobsidecarSubnetCount := cfg.BlobsidecarSubnetCount
blobsidecarSubnetCountElectra := cfg.BlobsidecarSubnetCountElectra
// Pre-compute digests using current config state
altairDigest := params.ForkDigest(altairForkEpoch)
bellatrixDigest := params.ForkDigest(bellatrixForkEpoch)
capellaDigest := params.ForkDigest(capellaForkEpoch)
denebDigest := params.ForkDigest(denebForkEpoch)
electraDigest := params.ForkDigest(electraForkEpoch)
fuluDigest := params.ForkDigest(fuluForkEpoch)
tests := []struct {
name string
svcCreator func(t *testing.T) *Service
checkRegistration func(t *testing.T, s *Service)
epochAtRegistration func(primitives.Epoch) primitives.Epoch
forkEpoch primitives.Epoch
nextForkEpoch primitives.Epoch
forkDigest [4]byte
nextForkDigest [4]byte
}{
{
name: "no fork in the next epoch",
forkEpoch: altairForkEpoch,
forkDigest: altairDigest,
epochAtRegistration: func(e primitives.Epoch) primitives.Epoch { return e - 2 },
nextForkEpoch: bellatrixForkEpoch,
nextForkDigest: bellatrixDigest,
checkRegistration: func(t *testing.T, s *Service) {},
},
{
name: "altair fork in the next epoch",
forkEpoch: altairForkEpoch,
forkDigest: altairDigest,
epochAtRegistration: func(e primitives.Epoch) primitives.Epoch { return e - 1 },
nextForkEpoch: bellatrixForkEpoch,
nextForkDigest: bellatrixDigest,
checkRegistration: func(t *testing.T, s *Service) {
expected := fmt.Sprintf(p2p.SyncContributionAndProofSubnetTopicFormat+s.cfg.p2p.Encoding().ProtocolSuffix(), altairDigest)
assert.Equal(t, true, s.subHandler.topicExists(expected), "subnet topic doesn't exist")
},
},
{
name: "capella fork in the next epoch",
forkEpoch: capellaForkEpoch,
forkDigest: capellaDigest,
nextForkEpoch: denebForkEpoch,
nextForkDigest: denebDigest,
epochAtRegistration: func(e primitives.Epoch) primitives.Epoch { return e - 1 },
checkRegistration: func(t *testing.T, s *Service) {
expected := fmt.Sprintf(p2p.BlsToExecutionChangeSubnetTopicFormat+s.cfg.p2p.Encoding().ProtocolSuffix(), capellaDigest)
assert.Equal(t, true, s.subHandler.topicExists(expected), "subnet topic doesn't exist")
},
},
{
name: "deneb fork in the next epoch",
forkEpoch: denebForkEpoch,
forkDigest: denebDigest,
nextForkEpoch: electraForkEpoch,
nextForkDigest: electraDigest,
epochAtRegistration: func(e primitives.Epoch) primitives.Epoch { return e - 1 },
checkRegistration: func(t *testing.T, s *Service) {
subIndices := mapFromCount(blobsidecarSubnetCount)
for idx := range subIndices {
topic := fmt.Sprintf(p2p.BlobSubnetTopicFormat, denebDigest, idx)
expected := topic + s.cfg.p2p.Encoding().ProtocolSuffix()
assert.Equal(t, true, s.subHandler.topicExists(expected), fmt.Sprintf("subnet topic %s doesn't exist", expected))
}
},
},
{
name: "electra fork in the next epoch",
forkEpoch: electraForkEpoch,
forkDigest: electraDigest,
nextForkEpoch: fuluForkEpoch,
nextForkDigest: fuluDigest,
epochAtRegistration: func(e primitives.Epoch) primitives.Epoch { return e - 1 },
checkRegistration: func(t *testing.T, s *Service) {
subIndices := mapFromCount(blobsidecarSubnetCountElectra)
for idx := range subIndices {
topic := fmt.Sprintf(p2p.BlobSubnetTopicFormat, electraDigest, idx)
expected := topic + s.cfg.p2p.Encoding().ProtocolSuffix()
assert.Equal(t, true, s.subHandler.topicExists(expected), fmt.Sprintf("subnet topic %s doesn't exist", expected))
}
},
},
{
name: "fulu fork in the next epoch; should not have blob topics",
forkEpoch: fuluForkEpoch,
forkDigest: fuluDigest,
nextForkEpoch: fuluForkEpoch,
nextForkDigest: fuluDigest,
epochAtRegistration: func(e primitives.Epoch) primitives.Epoch { return e - 1 },
checkRegistration: func(t *testing.T, s *Service) {
// Advance to two epochs after Fulu activation and assert no blob topics remain.
target := fuluForkEpoch + 2
s.cfg.clock = defaultClockWithTimeAtEpoch(target)
s.subscriptionController.updateActiveTopicFamilies(s.cfg.clock.CurrentEpoch())
for _, topic := range s.subHandler.allTopics() {
if strings.Contains(topic, "/"+p2p.GossipBlobSidecarMessage) {
t.Fatalf("blob topic still exists after Fulu+2: %s", topic)
}
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
current := tt.epochAtRegistration(tt.forkEpoch)
s := testSubscriptionControllerService(t, current)
s.subscriptionController.updateActiveTopicFamilies(s.cfg.clock.CurrentEpoch())
tt.checkRegistration(t, s)
if current != tt.forkEpoch-1 {
return
}
// Ensure the topics were registered for the upcoming fork
// Use pre-computed digest from test struct to avoid race with parallel tests
assert.Equal(t, true, s.subHandler.digestExists(tt.forkDigest))
// After this point we are checking deregistration, which doesn't apply if there isn't a higher
// nextForkEpoch.
if tt.forkEpoch >= tt.nextForkEpoch {
return
}
// Move the clock to just before the next fork epoch and ensure deregistration is correct
s.cfg.clock = defaultClockWithTimeAtEpoch(tt.nextForkEpoch - 1)
s.subscriptionController.updateActiveTopicFamilies(s.cfg.clock.CurrentEpoch())
s.subscriptionController.updateActiveTopicFamilies(tt.nextForkEpoch)
assert.Equal(t, true, s.subHandler.digestExists(tt.forkDigest))
// deregister as if it is the epoch after the next fork epoch
s.subscriptionController.updateActiveTopicFamilies(tt.nextForkEpoch + 1)
assert.Equal(t, false, s.subHandler.digestExists(tt.forkDigest))
assert.Equal(t, true, s.subHandler.digestExists(tt.nextForkDigest))
})
}
}
func TestSubscriptionController_ExtractTopics(t *testing.T) {
params.SetupTestConfigCleanup(t)
genesis.StoreEmbeddedDuringTest(t, params.BeaconConfig().ConfigName)
type tc struct {
name string
setup func(*SubscriptionController)
ctx func() context.Context
node *enode.Node
want []string
wantErr bool
}
dummyNode := new(enode.Node)
tests := []tc{
{
name: "nil node returns error",
setup: func(g *SubscriptionController) {},
ctx: func() context.Context { return context.Background() },
node: nil,
want: nil,
wantErr: true,
},
{
name: "no families yields empty",
setup: func(g *SubscriptionController) {},
ctx: func() context.Context { return context.Background() },
node: dummyNode,
want: []string{},
wantErr: false,
},
{
name: "static family ignored",
setup: func(g *SubscriptionController) {
g.mu.Lock()
g.activeTopicFamilies[topicFamilyKey{topicName: "static", forkDigest: [4]byte{1, 2, 3, 4}}] = &staticTopicFamily{name: "StaticFam"}
g.mu.Unlock()
},
ctx: func() context.Context { return context.Background() },
node: dummyNode,
want: []string{},
wantErr: false,
},
{
name: "single dynamic family topics returned",
setup: func(g *SubscriptionController) {
fam := &testDynFamly{topics: []string{"t1", "t2"}, name: "Dyn1"}
g.mu.Lock()
g.activeTopicFamilies[topicFamilyKey{topicName: "dyn1", forkDigest: [4]byte{0}}] = fam
g.mu.Unlock()
},
ctx: func() context.Context { return context.Background() },
node: dummyNode,
want: []string{"t1", "t2"},
wantErr: false,
},
{
name: "multiple dynamic families de-dup",
setup: func(g *SubscriptionController) {
f1 := &testDynFamly{topics: []string{"t1", "t2"}, name: "Dyn1"}
f2 := &testDynFamly{topics: []string{"t2", "t3"}, name: "Dyn2"}
g.mu.Lock()
g.activeTopicFamilies[topicFamilyKey{topicName: "static", forkDigest: [4]byte{1, 2, 3, 4}}] = &staticTopicFamily{name: "StaticFam"}
g.activeTopicFamilies[topicFamilyKey{topicName: "dyn1", forkDigest: [4]byte{0}}] = f1
g.activeTopicFamilies[topicFamilyKey{topicName: "dyn2", forkDigest: [4]byte{0}}] = f2
g.mu.Unlock()
},
ctx: func() context.Context { return context.Background() },
node: dummyNode,
want: []string{"t1", "t2", "t3"},
wantErr: false,
},
{
name: "mixed static and dynamic",
setup: func(g *SubscriptionController) {
f1 := &testDynFamly{topics: []string{"a", "b"}, name: "Dyn"}
s1 := &staticTopicFamily{name: "Static"}
g.mu.Lock()
g.activeTopicFamilies[topicFamilyKey{topicName: "dyn", forkDigest: [4]byte{9}}] = f1
g.activeTopicFamilies[topicFamilyKey{topicName: "static", forkDigest: [4]byte{9}}] = s1
g.mu.Unlock()
},
ctx: func() context.Context { return context.Background() },
node: dummyNode,
want: []string{"a", "b"},
wantErr: false,
},
}
s := &Service{}
g := NewSubscriptionController(context.Background(), s)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset families for each subtest
g.mu.Lock()
g.activeTopicFamilies = make(map[topicFamilyKey]TopicFamily)
g.mu.Unlock()
tt.setup(g)
topics, err := g.ExtractTopics(tt.ctx(), tt.node)
if tt.wantErr {
require.NotNil(t, err)
return
}
require.NoError(t, err)
got := map[string]bool{}
for _, tpc := range topics {
got[tpc] = true
}
want := map[string]bool{}
for _, tpc := range tt.want {
want[tpc] = true
}
require.Equal(t, len(want), len(got))
for k := range want {
require.Equal(t, true, got[k], "missing topic %s", k)
}
})
}
}
func TestSubscriptionController_GetCurrentActiveTopicsWithMinPeerCount(t *testing.T) {
params.SetupTestConfigCleanup(t)
genesis.StoreEmbeddedDuringTest(t, params.BeaconConfig().ConfigName)
tests := []struct {
name string
setup func(*SubscriptionController)
want map[string]int
}{
{
name: "no families yields empty map",
setup: func(_ *SubscriptionController) {},
want: map[string]int{},
},
{
name: "static family ignored",
setup: func(g *SubscriptionController) {
g.mu.Lock()
g.activeTopicFamilies[topicFamilyKey{topicName: "static", forkDigest: [4]byte{1, 2, 3, 4}}] = &staticTopicFamily{name: "StaticFam"}
g.mu.Unlock()
},
want: map[string]int{},
},
{
name: "single dynamic family returns topics with min peer counts",
setup: func(g *SubscriptionController) {
fam := &testDynFamly{
name: "Dyn1",
topicsWithMinPeers: map[string]int{"topic/a": 8, "topic/b": 6},
}
g.mu.Lock()
g.activeTopicFamilies[topicFamilyKey{topicName: "dyn1", forkDigest: [4]byte{0}}] = fam
g.mu.Unlock()
},
want: map[string]int{"topic/a": 8, "topic/b": 6},
},
{
name: "dynamic family with nil topicsWithMinPeers returns empty",
setup: func(g *SubscriptionController) {
fam := &testDynFamly{
name: "DynNil",
topicsWithMinPeers: nil,
}
g.mu.Lock()
g.activeTopicFamilies[topicFamilyKey{topicName: "dynnil", forkDigest: [4]byte{0}}] = fam
g.mu.Unlock()
},
want: map[string]int{},
},
{
name: "dynamic family with empty topicsWithMinPeers returns empty",
setup: func(g *SubscriptionController) {
fam := &testDynFamly{
name: "DynEmpty",
topicsWithMinPeers: map[string]int{},
}
g.mu.Lock()
g.activeTopicFamilies[topicFamilyKey{topicName: "dynempty", forkDigest: [4]byte{0}}] = fam
g.mu.Unlock()
},
want: map[string]int{},
},
{
name: "multiple dynamic families with disjoint topics",
setup: func(g *SubscriptionController) {
f1 := &testDynFamly{
name: "Dyn1",
topicsWithMinPeers: map[string]int{"topic/a": 8, "topic/b": 6},
}
f2 := &testDynFamly{
name: "Dyn2",
topicsWithMinPeers: map[string]int{"topic/c": 4, "topic/d": 2},
}
g.mu.Lock()
g.activeTopicFamilies[topicFamilyKey{topicName: "dyn1", forkDigest: [4]byte{0}}] = f1
g.activeTopicFamilies[topicFamilyKey{topicName: "dyn2", forkDigest: [4]byte{0}}] = f2
g.mu.Unlock()
},
want: map[string]int{"topic/a": 8, "topic/b": 6, "topic/c": 4, "topic/d": 2},
},
{
name: "multiple dynamic families with overlapping topics - counts are summed",
setup: func(g *SubscriptionController) {
f1 := &testDynFamly{
name: "Dyn1",
topicsWithMinPeers: map[string]int{"topic/shared": 5, "topic/a": 3},
}
f2 := &testDynFamly{
name: "Dyn2",
topicsWithMinPeers: map[string]int{"topic/shared": 7, "topic/b": 2},
}
g.mu.Lock()
g.activeTopicFamilies[topicFamilyKey{topicName: "dyn1", forkDigest: [4]byte{0}}] = f1
g.activeTopicFamilies[topicFamilyKey{topicName: "dyn2", forkDigest: [4]byte{0}}] = f2
g.mu.Unlock()
},
// topic/shared: 5 + 7 = 12
want: map[string]int{"topic/shared": 12, "topic/a": 3, "topic/b": 2},
},
{
name: "mixed static and dynamic families - only dynamic counted",
setup: func(g *SubscriptionController) {
dynFam := &testDynFamly{
name: "Dyn",
topicsWithMinPeers: map[string]int{"topic/dyn": 8},
}
staticFam := &staticTopicFamily{name: "Static"}
g.mu.Lock()
g.activeTopicFamilies[topicFamilyKey{topicName: "dyn", forkDigest: [4]byte{9}}] = dynFam
g.activeTopicFamilies[topicFamilyKey{topicName: "static", forkDigest: [4]byte{9}}] = staticFam
g.mu.Unlock()
},
want: map[string]int{"topic/dyn": 8},
},
{
name: "single topic with zero peer count",
setup: func(g *SubscriptionController) {
fam := &testDynFamly{
name: "DynZero",
topicsWithMinPeers: map[string]int{"topic/zero": 0},
}
g.mu.Lock()
g.activeTopicFamilies[topicFamilyKey{topicName: "dynzero", forkDigest: [4]byte{0}}] = fam
g.mu.Unlock()
},
want: map[string]int{"topic/zero": 0},
},
}
current := params.BeaconConfig().AltairForkEpoch
s := testSubscriptionControllerService(t, current)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset families for each subtest
s.subscriptionController.mu.Lock()
s.subscriptionController.activeTopicFamilies = make(map[topicFamilyKey]TopicFamily)
s.subscriptionController.mu.Unlock()
tt.setup(s.subscriptionController)
got := s.subscriptionController.GetCurrentActiveTopicsWithMinPeerCount()
require.Equal(t, len(tt.want), len(got), "result length mismatch")
for topic, expectedCount := range tt.want {
actualCount, exists := got[topic]
require.Equal(t, true, exists, "expected topic %s not found in result", topic)
require.Equal(t, expectedCount, actualCount, "peer count mismatch for topic %s", topic)
}
})
}
}

View File

@@ -35,6 +35,7 @@ func (s *subTopicHandler) addTopic(topic string, sub *pubsub.Subscription) {
s.digestMap[digest] += 1
}
// topicExists checks if a topic is currently tracked.
func (s *subTopicHandler) topicExists(topic string) bool {
s.RLock()
defer s.RUnlock()
@@ -64,6 +65,7 @@ func (s *subTopicHandler) removeTopic(topic string) {
}
}
// digestExists checks if a fork digest is currently tracked.
func (s *subTopicHandler) digestExists(digest [4]byte) bool {
s.RLock()
defer s.RUnlock()
@@ -72,6 +74,7 @@ func (s *subTopicHandler) digestExists(digest [4]byte) bool {
return ok && count > 0
}
// allTopics returns all currently tracked topics.
func (s *subTopicHandler) allTopics() []string {
s.RLock()
defer s.RUnlock()
@@ -83,6 +86,7 @@ func (s *subTopicHandler) allTopics() []string {
return topics
}
// subForTopic returns the subscription for a given topic.
func (s *subTopicHandler) subForTopic(topic string) *pubsub.Subscription {
s.RLock()
defer s.RUnlock()

View File

@@ -0,0 +1,277 @@
package sync
import (
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/ethereum/go-ethereum/p2p/enode"
"github.com/ethereum/go-ethereum/p2p/enr"
"github.com/pkg/errors"
)
// AttestationTopicFamily
var _ DynamicShardedTopicFamily = (*AttestationTopicFamily)(nil)
var (
attestationMinMeshPeers = 8
attestationMinFanoutPeers = 6
syncCommitteeMinMeshPeers = 8
syncCommitteeMinFanoutPeers = 6
dataColumnMinMeshPeers = 6
dataColumnMinFanoutPeers = 2
)
type AttestationTopicFamily struct {
*baseTopicFamily
}
// NewAttestationTopicFamily creates a new AttestationTopicFamily.
func NewAttestationTopicFamily(s *Service, nse params.NetworkScheduleEntry) *AttestationTopicFamily {
a := &AttestationTopicFamily{}
base := newBaseTopicFamily(s, nse, s.validateCommitteeIndexBeaconAttestation, s.committeeIndexBeaconAttestationSubscriber, a)
a.baseTopicFamily = base
return a
}
func (a *AttestationTopicFamily) Name() string {
return "AttestationTopicFamily"
}
// SubscribeForSlot subscribes to the topics for the given slot.
func (a *AttestationTopicFamily) SubscribeForSlot(slot primitives.Slot) {
a.subscribeToTopics(a.TopicsToSubscribeForSlot(slot))
}
// UnsubscribeForSlot unsubscribes from topics we no longer need for the slot.
func (a *AttestationTopicFamily) UnsubscribeForSlot(slot primitives.Slot) {
a.pruneTopicsExcept(a.TopicsToSubscribeForSlot(slot))
}
// TopicsToSubscribeFor returns the topics to subscribe to for a given slot.
func (a *AttestationTopicFamily) TopicsToSubscribeForSlot(slot primitives.Slot) []string {
return topicsFromSubnets(a.getSubnetsToJoin(slot), a)
}
// getFullTopicString builds the full topic string for an attestation subnet.
func (a *AttestationTopicFamily) getFullTopicString(subnet uint64) string {
return p2p.AttestationSubnetTopic(a.nse.ForkDigest, subnet)
}
// getSubnetsToJoin returns persistent and aggregator subnets.
func (a *AttestationTopicFamily) getSubnetsToJoin(slot primitives.Slot) map[uint64]bool {
return a.syncService.persistentAndAggregatorSubnetIndices(slot)
}
// getSubnetsForBroadcast returns subnets needed for attestation duties.
func (a *AttestationTopicFamily) getSubnetsForBroadcast(slot primitives.Slot) map[uint64]bool {
return attesterSubnetIndices(slot)
}
// ExtractTopicsForNode returns all topics for the given node that are relevant to this topic family.
func (a *AttestationTopicFamily) ExtractTopicsForNode(node *enode.Node) ([]string, error) {
return getTopicsForNode(a.syncService, a, node, p2p.AttestationSubnets)
}
// TopicsWithMinPeerCount returns all topics (mesh and fanout) with their respective min peer counts.
func (a *AttestationTopicFamily) TopicsWithMinPeerCount(slot primitives.Slot) map[string]int {
return topicsWithMinPeerCount(a, slot, attestationMinMeshPeers, attestationMinFanoutPeers)
}
// SyncCommitteeTopicFamily
var _ DynamicShardedTopicFamily = (*SyncCommitteeTopicFamily)(nil)
type SyncCommitteeTopicFamily struct {
*baseTopicFamily
}
// NewSyncCommitteeTopicFamily creates a new SyncCommitteeTopicFamily.
func NewSyncCommitteeTopicFamily(s *Service, nse params.NetworkScheduleEntry) *SyncCommitteeTopicFamily {
sc := &SyncCommitteeTopicFamily{}
base := newBaseTopicFamily(s, nse, s.validateSyncCommitteeMessage, s.syncCommitteeMessageSubscriber, sc)
sc.baseTopicFamily = base
return sc
}
func (s *SyncCommitteeTopicFamily) Name() string {
return "SyncCommitteeTopicFamily"
}
// SubscribeFor subscribes to the topics for the given slot.
func (s *SyncCommitteeTopicFamily) SubscribeForSlot(slot primitives.Slot) {
s.subscribeToTopics(s.TopicsToSubscribeForSlot(slot))
}
// UnsubscribeFor unsubscribes from topics we no longer need for the slot.
func (s *SyncCommitteeTopicFamily) UnsubscribeForSlot(slot primitives.Slot) {
s.pruneTopicsExcept(s.TopicsToSubscribeForSlot(slot))
}
// TopicsToSubscribeFor returns the topics to subscribe to for a given slot.
func (s *SyncCommitteeTopicFamily) TopicsToSubscribeForSlot(slot primitives.Slot) []string {
return topicsFromSubnets(s.getSubnetsToJoin(slot), s)
}
// getFullTopicString builds the full topic string for a sync committee subnet.
func (s *SyncCommitteeTopicFamily) getFullTopicString(subnet uint64) string {
return p2p.SyncCommitteeSubnetTopic(s.nse.ForkDigest, subnet)
}
// getSubnetsToJoin returns active sync committee subnets.
func (s *SyncCommitteeTopicFamily) getSubnetsToJoin(slot primitives.Slot) map[uint64]bool {
return s.syncService.activeSyncSubnetIndices(slot)
}
// getSubnetsForBroadcast returns nil as there are no separate peer requirements.
func (s *SyncCommitteeTopicFamily) getSubnetsForBroadcast(slot primitives.Slot) map[uint64]bool {
return nil
}
// ExtractTopicsForNode returns all topics for the given node that are relevant to this topic family.
func (s *SyncCommitteeTopicFamily) ExtractTopicsForNode(node *enode.Node) ([]string, error) {
return getTopicsForNode(s.syncService, s, node, p2p.SyncSubnets)
}
// TopicsWithMinPeerCount returns all topics (mesh and fanout) with their respective min peer counts.
func (s *SyncCommitteeTopicFamily) TopicsWithMinPeerCount(slot primitives.Slot) map[string]int {
return topicsWithMinPeerCount(s, slot, syncCommitteeMinMeshPeers, syncCommitteeMinFanoutPeers)
}
// DataColumnTopicFamily
var _ DynamicShardedTopicFamily = (*DataColumnTopicFamily)(nil)
type DataColumnTopicFamily struct {
*baseTopicFamily
}
// NewDataColumnTopicFamily creates a new DataColumnTopicFamily.
func NewDataColumnTopicFamily(s *Service, nse params.NetworkScheduleEntry) *DataColumnTopicFamily {
d := &DataColumnTopicFamily{}
base := newBaseTopicFamily(s, nse, s.validateDataColumn, s.dataColumnSubscriber, d)
d.baseTopicFamily = base
return d
}
func (d *DataColumnTopicFamily) Name() string {
return "DataColumnTopicFamily"
}
// SubscribeFor subscribes to the topics for the given slot.
func (d *DataColumnTopicFamily) SubscribeForSlot(slot primitives.Slot) {
d.subscribeToTopics(d.TopicsToSubscribeForSlot(slot))
}
// UnsubscribeForSlot unsubscribes from topics we no longer need for the slot.
func (d *DataColumnTopicFamily) UnsubscribeForSlot(slot primitives.Slot) {
d.pruneTopicsExcept(d.TopicsToSubscribeForSlot(slot))
}
// TopicsToSubscribeFor returns the topics to subscribe to for a given slot.
func (d *DataColumnTopicFamily) TopicsToSubscribeForSlot(slot primitives.Slot) []string {
return topicsFromSubnets(d.getSubnetsToJoin(slot), d)
}
// getFullTopicString builds the full topic string for a data column subnet.
func (d *DataColumnTopicFamily) getFullTopicString(subnet uint64) string {
return p2p.DataColumnSubnetTopic(d.nse.ForkDigest, subnet)
}
// getSubnetsToJoin returns data column subnets.
func (d *DataColumnTopicFamily) getSubnetsToJoin(slot primitives.Slot) map[uint64]bool {
return d.syncService.dataColumnSubnetIndices(slot)
}
// getSubnetsForBroadcast returns all data column subnets.
func (d *DataColumnTopicFamily) getSubnetsForBroadcast(slot primitives.Slot) map[uint64]bool {
return d.syncService.allDataColumnSubnets(slot)
}
// ExtractTopicsForNode returns all topics for the given node that are relevant to this topic family.
func (d *DataColumnTopicFamily) ExtractTopicsForNode(node *enode.Node) ([]string, error) {
return getTopicsForNode(d.syncService, d, node, p2p.DataColumnSubnets)
}
// TopicsWithMinPeerCount returns all topics (mesh and fanout) with their respective min peer counts.
func (d *DataColumnTopicFamily) TopicsWithMinPeerCount(slot primitives.Slot) map[string]int {
return topicsWithMinPeerCount(d, slot, dataColumnMinMeshPeers, dataColumnMinFanoutPeers)
}
type nodeSubnetExtractor func(id enode.ID, n *enode.Node, r *enr.Record) (map[uint64]bool, error)
type dynamicSubnetFamily interface {
getSubnetsToJoin(primitives.Slot) map[uint64]bool
getSubnetsForBroadcast(primitives.Slot) map[uint64]bool
getFullTopicString(subnet uint64) string
}
func getTopicsForNode(
s *Service,
tf dynamicSubnetFamily,
node *enode.Node,
extractor nodeSubnetExtractor,
) ([]string, error) {
if node == nil {
return nil, errors.New("enode is nil")
}
currentSlot := s.cfg.clock.CurrentSlot()
neededSubnets := computeNeededSubnets(tf, currentSlot)
nodeSubnets, err := extractor(node.ID(), node, node.Record())
if err != nil {
return nil, err
}
var topics []string
for subnet := range neededSubnets {
if nodeSubnets[subnet] {
topics = append(topics, tf.getFullTopicString(subnet))
}
}
return topics, nil
}
func computeNeededSubnets(tf dynamicSubnetFamily, slot primitives.Slot) map[uint64]bool {
subnetsToJoin := tf.getSubnetsToJoin(slot)
subnetsRequiringPeers := tf.getSubnetsForBroadcast(slot)
neededSubnets := make(map[uint64]bool, len(subnetsToJoin)+len(subnetsRequiringPeers))
for subnet := range subnetsToJoin {
neededSubnets[subnet] = true
}
for subnet := range subnetsRequiringPeers {
neededSubnets[subnet] = true
}
return neededSubnets
}
func topicsFromSubnets(subnets map[uint64]bool, tf dynamicSubnetFamily) []string {
topics := make([]string, 0, len(subnets))
for s := range subnets {
topics = append(topics, tf.getFullTopicString(s))
}
return topics
}
// topicsWithMinPeerCount returns all topics (mesh and fanout) with their respective min peer counts.
// If a subnet appears in both mesh and fanout, the mesh peer count is used.
func topicsWithMinPeerCount(tf dynamicSubnetFamily, slot primitives.Slot, minMeshPeers int, minFanoutPeers int) map[string]int {
meshSubnets := tf.getSubnetsToJoin(slot)
fanoutSubnets := tf.getSubnetsForBroadcast(slot)
result := make(map[string]int, len(meshSubnets)+len(fanoutSubnets))
// Add mesh topics with mesh min peer count
for subnet := range meshSubnets {
topic := tf.getFullTopicString(subnet)
result[topic] = minMeshPeers
}
// Add fanout topics with fanout min peer count (only if not already in mesh)
for subnet := range fanoutSubnets {
topic := tf.getFullTopicString(subnet)
if _, exists := result[topic]; !exists {
result[topic] = minFanoutPeers
}
}
return result
}

View File

@@ -0,0 +1,137 @@
package sync
import (
"fmt"
"testing"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/stretchr/testify/require"
)
// mockDynamicSubnetFamily is a test implementation of dynamicSubnetFamily.
type mockDynamicSubnetFamily struct {
meshSubnets map[uint64]bool
fanoutSubnets map[uint64]bool
topicPrefix string
}
func (m *mockDynamicSubnetFamily) getSubnetsToJoin(_ primitives.Slot) map[uint64]bool {
return m.meshSubnets
}
func (m *mockDynamicSubnetFamily) getSubnetsForBroadcast(_ primitives.Slot) map[uint64]bool {
return m.fanoutSubnets
}
func (m *mockDynamicSubnetFamily) getFullTopicString(subnet uint64) string {
return fmt.Sprintf("%s/subnet/%d", m.topicPrefix, subnet)
}
func TestTopicsWithMinPeerCount(t *testing.T) {
tests := []struct {
name string
meshSubnets map[uint64]bool
fanoutSubnets map[uint64]bool
minMeshPeers int
minFanoutPeers int
expected map[string]int
}{
{
name: "empty subnets returns empty map",
meshSubnets: nil,
fanoutSubnets: nil,
minMeshPeers: 8,
minFanoutPeers: 6,
expected: map[string]int{},
},
{
name: "empty maps returns empty map",
meshSubnets: map[uint64]bool{},
fanoutSubnets: map[uint64]bool{},
minMeshPeers: 8,
minFanoutPeers: 6,
expected: map[string]int{},
},
{
name: "only mesh subnets",
meshSubnets: map[uint64]bool{1: true, 2: true, 3: true},
fanoutSubnets: nil,
minMeshPeers: 8,
minFanoutPeers: 6,
expected: map[string]int{
"test/subnet/1": 8,
"test/subnet/2": 8,
"test/subnet/3": 8,
},
},
{
name: "only fanout subnets",
meshSubnets: nil,
fanoutSubnets: map[uint64]bool{4: true, 5: true},
minMeshPeers: 8,
minFanoutPeers: 6,
expected: map[string]int{
"test/subnet/4": 6,
"test/subnet/5": 6,
},
},
{
name: "mesh and fanout with no overlap",
meshSubnets: map[uint64]bool{1: true, 2: true},
fanoutSubnets: map[uint64]bool{3: true, 4: true},
minMeshPeers: 8,
minFanoutPeers: 6,
expected: map[string]int{
"test/subnet/1": 8,
"test/subnet/2": 8,
"test/subnet/3": 6,
"test/subnet/4": 6,
},
},
{
name: "fanout subset of mesh - all get mesh peer count",
meshSubnets: map[uint64]bool{1: true, 2: true, 3: true, 4: true},
fanoutSubnets: map[uint64]bool{2: true, 3: true},
minMeshPeers: 8,
minFanoutPeers: 6,
expected: map[string]int{
"test/subnet/1": 8,
"test/subnet/2": 8, // in both, mesh takes precedence
"test/subnet/3": 8, // in both, mesh takes precedence
"test/subnet/4": 8,
},
},
{
name: "mesh subset of fanout - mesh subnets get mesh count, remaining get fanout",
meshSubnets: map[uint64]bool{2: true, 3: true},
fanoutSubnets: map[uint64]bool{1: true, 2: true, 3: true, 4: true},
minMeshPeers: 8,
minFanoutPeers: 6,
expected: map[string]int{
"test/subnet/1": 6, // fanout only
"test/subnet/2": 8, // in both, mesh takes precedence
"test/subnet/3": 8, // in both, mesh takes precedence
"test/subnet/4": 6, // fanout only
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &mockDynamicSubnetFamily{
meshSubnets: tt.meshSubnets,
fanoutSubnets: tt.fanoutSubnets,
topicPrefix: "test",
}
result := topicsWithMinPeerCount(mock, 0, tt.minMeshPeers, tt.minFanoutPeers)
require.Equal(t, len(tt.expected), len(result), "result length mismatch")
for topic, expectedCount := range tt.expected {
actualCount, exists := result[topic]
require.True(t, exists, "expected topic %s not found in result", topic)
require.Equal(t, expectedCount, actualCount, "peer count mismatch for topic %s", topic)
}
})
}
}

View File

@@ -0,0 +1,38 @@
package sync
import (
"fmt"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p"
"github.com/OffchainLabs/prysm/v7/config/params"
)
var _ ShardedTopicFamily = (*BlobTopicFamily)(nil)
// BlobTopicFamily represents a static-subnet family instance for a specific blob subnet index.
type BlobTopicFamily struct {
*baseTopicFamily
subnetIndex uint64
}
func NewBlobTopicFamily(s *Service, nse params.NetworkScheduleEntry, subnetIndex uint64) *BlobTopicFamily {
b := &BlobTopicFamily{
subnetIndex: subnetIndex,
}
base := newBaseTopicFamily(s, nse, s.validateBlob, s.blobSubscriber, b)
b.baseTopicFamily = base
return b
}
func (b *BlobTopicFamily) Name() string {
return fmt.Sprintf("BlobTopicFamily-%d", b.subnetIndex)
}
// Subscribe subscribes to the static subnet topic. Slot is ignored for this topic family.
func (b *BlobTopicFamily) Subscribe() {
b.subscribeToTopics([]string{b.getFullTopicString()})
}
func (b *BlobTopicFamily) getFullTopicString() string {
return p2p.BlobSubnetTopic(b.nse.ForkDigest, b.subnetIndex)
}

View File

@@ -0,0 +1,247 @@
package sync
import (
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p"
"github.com/OffchainLabs/prysm/v7/config/params"
)
// Blocks
var _ ShardedTopicFamily = (*BlockTopicFamily)(nil)
type BlockTopicFamily struct {
*baseTopicFamily
}
func NewBlockTopicFamily(s *Service, nse params.NetworkScheduleEntry) *BlockTopicFamily {
b := &BlockTopicFamily{}
base := newBaseTopicFamily(s, nse, s.validateBeaconBlockPubSub, s.beaconBlockSubscriber, b)
b.baseTopicFamily = base
return b
}
func (b *BlockTopicFamily) Name() string {
return "BlockTopicFamily"
}
// Subscribe subscribes to the topic.
func (b *BlockTopicFamily) Subscribe() {
b.subscribeToTopics([]string{b.getFullTopicString()})
}
func (b *BlockTopicFamily) getFullTopicString() string {
return p2p.BlockSubnetTopic(b.nse.ForkDigest)
}
// Aggregate and Proof
var _ ShardedTopicFamily = (*AggregateAndProofTopicFamily)(nil)
type AggregateAndProofTopicFamily struct {
*baseTopicFamily
}
func NewAggregateAndProofTopicFamily(s *Service, nse params.NetworkScheduleEntry) *AggregateAndProofTopicFamily {
a := &AggregateAndProofTopicFamily{}
base := newBaseTopicFamily(s, nse, s.validateAggregateAndProof, s.beaconAggregateProofSubscriber, a)
a.baseTopicFamily = base
return a
}
func (a *AggregateAndProofTopicFamily) Name() string {
return "AggregateAndProofTopicFamily"
}
// Subscribe subscribes to the topic.
func (a *AggregateAndProofTopicFamily) Subscribe() {
a.subscribeToTopics([]string{a.getFullTopicString()})
}
func (a *AggregateAndProofTopicFamily) getFullTopicString() string {
return p2p.AggregateAndProofSubnetTopic(a.nse.ForkDigest)
}
// Voluntary Exit
var _ ShardedTopicFamily = (*VoluntaryExitTopicFamily)(nil)
type VoluntaryExitTopicFamily struct {
*baseTopicFamily
}
func NewVoluntaryExitTopicFamily(s *Service, nse params.NetworkScheduleEntry) *VoluntaryExitTopicFamily {
v := &VoluntaryExitTopicFamily{}
base := newBaseTopicFamily(s, nse, s.validateVoluntaryExit, s.voluntaryExitSubscriber, v)
v.baseTopicFamily = base
return v
}
func (v *VoluntaryExitTopicFamily) Name() string {
return "VoluntaryExitTopicFamily"
}
// Subscribe subscribes to the topic. Slot is ignored for this topic family.
func (v *VoluntaryExitTopicFamily) Subscribe() {
v.subscribeToTopics([]string{v.getFullTopicString()})
}
func (v *VoluntaryExitTopicFamily) getFullTopicString() string {
return p2p.VoluntaryExitSubnetTopic(v.nse.ForkDigest)
}
// Proposer Slashing
var _ ShardedTopicFamily = (*ProposerSlashingTopicFamily)(nil)
type ProposerSlashingTopicFamily struct {
*baseTopicFamily
}
func NewProposerSlashingTopicFamily(s *Service, nse params.NetworkScheduleEntry) *ProposerSlashingTopicFamily {
p := &ProposerSlashingTopicFamily{}
base := newBaseTopicFamily(s, nse, s.validateProposerSlashing, s.proposerSlashingSubscriber, p)
p.baseTopicFamily = base
return p
}
func (p *ProposerSlashingTopicFamily) Name() string {
return "ProposerSlashingTopicFamily"
}
// Subscribe subscribes to the topic. Slot is ignored for this topic family.
func (p *ProposerSlashingTopicFamily) Subscribe() {
p.subscribeToTopics([]string{p.getFullTopicString()})
}
func (p *ProposerSlashingTopicFamily) getFullTopicString() string {
return p2p.ProposerSlashingSubnetTopic(p.nse.ForkDigest)
}
// Attester Slashing
var _ ShardedTopicFamily = (*AttesterSlashingTopicFamily)(nil)
type AttesterSlashingTopicFamily struct {
*baseTopicFamily
}
func NewAttesterSlashingTopicFamily(s *Service, nse params.NetworkScheduleEntry) *AttesterSlashingTopicFamily {
a := &AttesterSlashingTopicFamily{}
base := newBaseTopicFamily(s, nse, s.validateAttesterSlashing, s.attesterSlashingSubscriber, a)
a.baseTopicFamily = base
return a
}
func (a *AttesterSlashingTopicFamily) Name() string {
return "AttesterSlashingTopicFamily"
}
// Subscribe subscribes to the topic. Slot is ignored for this topic family.
func (a *AttesterSlashingTopicFamily) Subscribe() {
a.subscribeToTopics([]string{a.getFullTopicString()})
}
func (a *AttesterSlashingTopicFamily) getFullTopicString() string {
return p2p.AttesterSlashingSubnetTopic(a.nse.ForkDigest)
}
// Sync Contribution and Proof (Altair+)
var _ ShardedTopicFamily = (*SyncContributionAndProofTopicFamily)(nil)
type SyncContributionAndProofTopicFamily struct{ *baseTopicFamily }
func NewSyncContributionAndProofTopicFamily(s *Service, nse params.NetworkScheduleEntry) *SyncContributionAndProofTopicFamily {
sc := &SyncContributionAndProofTopicFamily{}
base := newBaseTopicFamily(s, nse, s.validateSyncContributionAndProof, s.syncContributionAndProofSubscriber, sc)
sc.baseTopicFamily = base
return sc
}
func (sc *SyncContributionAndProofTopicFamily) Name() string {
return "SyncContributionAndProofTopicFamily"
}
// Subscribe subscribes to the topic. Slot is ignored for this topic family.
func (sc *SyncContributionAndProofTopicFamily) Subscribe() {
sc.subscribeToTopics([]string{sc.getFullTopicString()})
}
func (sc *SyncContributionAndProofTopicFamily) getFullTopicString() string {
return p2p.SyncContributionAndProofSubnetTopic(sc.nse.ForkDigest)
}
// Light Client Optimistic Update (Altair+)
var _ ShardedTopicFamily = (*LightClientOptimisticUpdateTopicFamily)(nil)
type LightClientOptimisticUpdateTopicFamily struct {
*baseTopicFamily
}
func NewLightClientOptimisticUpdateTopicFamily(s *Service, nse params.NetworkScheduleEntry) *LightClientOptimisticUpdateTopicFamily {
l := &LightClientOptimisticUpdateTopicFamily{}
base := newBaseTopicFamily(s, nse, s.validateLightClientOptimisticUpdate, noopHandler, l)
l.baseTopicFamily = base
return l
}
func (l *LightClientOptimisticUpdateTopicFamily) Name() string {
return "LightClientOptimisticUpdateTopicFamily"
}
// Subscribe subscribes to the topic. Slot is ignored for this topic family.
func (l *LightClientOptimisticUpdateTopicFamily) Subscribe() {
l.subscribeToTopics([]string{l.getFullTopicString()})
}
func (l *LightClientOptimisticUpdateTopicFamily) getFullTopicString() string {
return p2p.LcOptimisticToTopic(l.nse.ForkDigest)
}
// Light Client Finality Update (Altair+)
var _ ShardedTopicFamily = (*LightClientFinalityUpdateTopicFamily)(nil)
type LightClientFinalityUpdateTopicFamily struct {
*baseTopicFamily
}
func NewLightClientFinalityUpdateTopicFamily(s *Service, nse params.NetworkScheduleEntry) *LightClientFinalityUpdateTopicFamily {
l := &LightClientFinalityUpdateTopicFamily{}
base := newBaseTopicFamily(s, nse, s.validateLightClientFinalityUpdate, noopHandler, l)
l.baseTopicFamily = base
return l
}
func (l *LightClientFinalityUpdateTopicFamily) Name() string {
return "LightClientFinalityUpdateTopicFamily"
}
// Subscribe subscribes to the topic. Slot is ignored for this topic family.
func (l *LightClientFinalityUpdateTopicFamily) Subscribe() {
l.subscribeToTopics([]string{l.getFullTopicString()})
}
func (l *LightClientFinalityUpdateTopicFamily) getFullTopicString() string {
return p2p.LcFinalityToTopic(l.nse.ForkDigest)
}
// BLS to Execution Change (Capella+)
var _ ShardedTopicFamily = (*BlsToExecutionChangeTopicFamily)(nil)
type BlsToExecutionChangeTopicFamily struct {
*baseTopicFamily
}
func NewBlsToExecutionChangeTopicFamily(s *Service, nse params.NetworkScheduleEntry) *BlsToExecutionChangeTopicFamily {
b := &BlsToExecutionChangeTopicFamily{}
base := newBaseTopicFamily(s, nse, s.validateBlsToExecutionChange, s.blsToExecutionChangeSubscriber, b)
b.baseTopicFamily = base
return b
}
func (b *BlsToExecutionChangeTopicFamily) Name() string {
return "BlsToExecutionChangeTopicFamily"
}
// Subscribe subscribes to the topic. Slot is ignored for this topic family.
func (b *BlsToExecutionChangeTopicFamily) Subscribe() {
b.subscribeToTopics([]string{b.getFullTopicString()})
}
func (b *BlsToExecutionChangeTopicFamily) getFullTopicString() string {
return p2p.BlsToExecutionChangeSubnetTopic(b.nse.ForkDigest)
}

View File

@@ -0,0 +1,3 @@
### Added
- A Gossipsub control plane with topic abstractions, a peer crawler and a peer controller.

1
go.mod
View File

@@ -171,6 +171,7 @@ require (
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/koron/go-ssdp v0.0.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/leodido/go-urn v1.2.3 // indirect
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
github.com/libp2p/go-flow-metrics v0.2.0 // indirect