Compare commits

...

104 Commits

Author SHA1 Message Date
Manu NALEPA
b57c35a468 manu 2025-12-22 11:23:18 +01:00
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
Preston Van Loon
b360794c9c Update CHANGELOG.md for v7.1.0 release (#16127)
**What type of PR is this?**

Documentation

**What does this PR do? Why is it needed?**

Changelog

**Which issues(s) does this PR fix?**

Fixes #

**Other notes for review**

**Acknowledgements**

- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.
2025-12-11 22:48:33 +00: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
Aarsh Shah
0fc9ab925a feat: add support for detecting and logging per address reachability via libp2p AutoNAT v2 (#16100)
**What type of PR is this?**
Feature

**What does this PR do? Why is it needed?**

This PR adds support for detecting and logging per address reachability
via libp2p AutoNAT v2. See
https://github.com/libp2p/go-libp2p/releases/tag/v0.42.0 for details.
This PR also upgrades Prysm to libp2p v0.42.0

**Which issues(s) does this PR fix?**

Fixes #https://github.com/OffchainLabs/prysm/issues/16098

**Other notes for review**

**Acknowledgements**

- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.
2025-12-11 11:56:52 +00:00
aarshkshah1992
9b6cd96012 changes as per review 2025-12-11 15:56:08 +04:00
satushh
dda5ee3334 Graffiti proposal design doc (#15983)
<!-- Thanks for sending a PR! Before submitting:

1. If this is your first PR, check out our contribution guide here
https://docs.prylabs.network/docs/contribute/contribution-guidelines
You will then need to sign our Contributor License Agreement (CLA),
which will show up as a comment from a bot in this pull request after
you open it. We cannot review code without a signed CLA.
2. Please file an associated tracking issue if this pull request is
non-trivial and requires context for our team to understand. All
features and most bug fixes should have
an associated issue with a design discussed and decided upon. Small bug
   fixes and documentation improvements don't need issues.
3. New features and bug fixes must have tests. Documentation may need to
be updated. If you're unsure what to update, send the PR, and we'll
discuss
   in review.
4. Note that PRs updating dependencies and new Go versions are not
accepted.
   Please file an issue instead.
5. A changelog entry is required for user facing issues.
-->

**What type of PR is this?**

Design Doc

**What does this PR do? Why is it needed?**

This PR adds a design doc for adding graffiti. The idea is to have it
populated judiciously so that we can get proper information about the
EL, CL and their corresponding version info. At the same time being
flexible enough with the user input.

**Which issues(s) does this PR fix?**

Fixes #

**Other notes for review**

**Acknowledgements**

- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.
2025-12-10 22:57:40 +00:00
Manu NALEPA
14c67376c3 Add test requirement to PULL_REQUEST_TEMPLATE.md (#16123)
**What type of PR is this?**
Other

**What does this PR do? Why is it needed?**
This pull request modifies the `PULL_REQUEST_TEMPLATE.md` to ensure the
developer checked that their PR works as expected.

Some contributors push some changes, without even running the modified
client once to see if their changes work as expected.

Avoidable back-and-forth trips between the contributor and the reviewers
could be prevented thanks to running the modified client.

**Acknowledgements**
- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.

---------

Co-authored-by: Preston Van Loon <pvanloon@offchainlabs.com>
2025-12-10 17:40:29 +00:00
Preston Van Loon
9c8b68a66d Update CHANGELOG.md for v7.0.1 release (#16107)
**What type of PR is this?**

Other

**What does this PR do? Why is it needed?**

**Which issues(s) does this PR fix?**

**Other notes for review**

Did not delete the fragments as they are still needed to generate v7.1.0
release notes. This release is all cherry-picks which would be included
in v7.1.0

**Acknowledgements**

- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.
2025-12-10 17:07:38 +00:00
Potuz
a3210157e2 Fix TOCTOU race validating attestations (#16105)
A TOCTOU issue was reported by EF security in which two attestations
being validated at the same time may result in both of them being
forwarded. The spec says that we need to forward only the first one.
2025-12-09 19:26:05 +00:00
satushh
1536d59e30 Remove unnecessary copy in Eth1DataHasEnoughSupport (#16118)
<!-- Thanks for sending a PR! Before submitting:

1. If this is your first PR, check out our contribution guide here
https://docs.prylabs.network/docs/contribute/contribution-guidelines
You will then need to sign our Contributor License Agreement (CLA),
which will show up as a comment from a bot in this pull request after
you open it. We cannot review code without a signed CLA.
2. Please file an associated tracking issue if this pull request is
non-trivial and requires context for our team to understand. All
features and most bug fixes should have
an associated issue with a design discussed and decided upon. Small bug
   fixes and documentation improvements don't need issues.
3. New features and bug fixes must have tests. Documentation may need to
be updated. If you're unsure what to update, send the PR, and we'll
discuss
   in review.
4. Note that PRs updating dependencies and new Go versions are not
accepted.
   Please file an issue instead.
5. A changelog entry is required for user facing issues.
-->

**What type of PR is this?**

Other

**What does this PR do? Why is it needed?**

- Remove unnecessary `Copy()` call in `Eth1DataHasEnoughSupport`
- `data.Copy()` was called on every iteration of the vote counting loop,
even though `AreEth1DataEqual` only reads the data and never mutates it.
- Additionally, `Eth1DataVotes()` already returns copies of all votes,
so state is protected regardless.

**Which issues(s) does this PR fix?**

Fixes #

**Other notes for review**

**Acknowledgements**

- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.
2025-12-09 19:02:36 +00:00
satushh
11e46a4560 Optimise for loop of MigrateToCold (#16101)
<!-- Thanks for sending a PR! Before submitting:

1. If this is your first PR, check out our contribution guide here
https://docs.prylabs.network/docs/contribute/contribution-guidelines
You will then need to sign our Contributor License Agreement (CLA),
which will show up as a comment from a bot in this pull request after
you open it. We cannot review code without a signed CLA.
2. Please file an associated tracking issue if this pull request is
non-trivial and requires context for our team to understand. All
features and most bug fixes should have
an associated issue with a design discussed and decided upon. Small bug
   fixes and documentation improvements don't need issues.
3. New features and bug fixes must have tests. Documentation may need to
be updated. If you're unsure what to update, send the PR, and we'll
discuss
   in review.
4. Note that PRs updating dependencies and new Go versions are not
accepted.
   Please file an issue instead.
5. A changelog entry is required for user facing issues.
-->

**What type of PR is this?**

 Other

**What does this PR do? Why is it needed?**

The for loop in MigrateToCold function was brute force in nature. It
could be improved by just directly jumping by `slotsPerArchivedPoint`
rather than going over every single slot.

```
for slot := oldFSlot; slot < fSlot; slot++ {
  ...
   if slot%s.slotsPerArchivedPoint == 0 && slot != 0 {
```
No need to do the modulo for every single slot.
We could just find the correct starting point and jump by
slotsPerArchivedPoint at a time.

**Which issues(s) does this PR fix?**

Fixes #

**Other notes for review**

**Acknowledgements**

- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.

---------

Co-authored-by: Potuz <potuz@prysmaticlabs.com>
2025-12-09 17:15:52 +00:00
Snezhkko
5a2e51b894 fix(rpc): incorrect constructor return type (#16084)
The constructor `NewStateRootNotFoundError` incorrectly returned
`StateNotFoundError`. This prevented handlers that rely on
errors.As(err, *lookup.StateRootNotFoundError) from matching and mapping
the error to HTTP 404. The function now returns
StateRootNotFoundError and constructs that type, restoring the intended
behavior for “state root not found” cases.

---------

Co-authored-by: Radosław Kapka <rkapka@wp.pl>
2025-12-09 13:56:00 +00: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
120 changed files with 5954 additions and 1723 deletions

View File

@@ -34,4 +34,5 @@ Fixes #
- [ ] I have read [CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [ ] I have included a uniquely named [changelog fragment file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [ ] I have added a description to this PR with sufficient context for reviewers to understand this PR.
- [ ] I have added a description with sufficient context for reviewers to understand this PR.
- [ ] I have tested that my changes work as expected and I added a testing plan to the PR description (if applicable).

7
.gitignore vendored
View File

@@ -38,6 +38,13 @@ metaData
# execution API authentication
jwt.hex
execution/
# local execution client data
execution/
# local documentation
CLAUDE.md
# manual testing
tmp

View File

@@ -4,6 +4,91 @@ All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
## [v7.1.0](https://github.com/prysmaticlabs/prysm/compare/v7.0.0...v7.1.0) - 2025-12-10
This release includes several key features/fixes. If you are running v7.0.0 then you should update to v7.0.1 or later and remove the flag `--disable-last-epoch-targets`.
Release highlights:
- Backfill is now supported in Fulu. Backfill from checkpoint sync now supports data columns. Run with `--enable-backfill` when using checkpoint sync.
- A new node configuration to custody enough data columns to reconstruct blobs. Use flag `--semi-supernode` to custody at least 50% of the data columns.
- Critical fixes in attestation processing.
A post mortem doc with full details on the mainnet attestation processing issue from December 4th is expected in the coming days.
### Added
- add fulu support to light client processing. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15995)
- Record data column gossip KZG batch verification latency in both the pooled worker and fallback paths so the `beacon_kzg_verification_data_column_batch_milliseconds` histogram reflects gossip traffic, annotated with `path` labels to distinguish the sources. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16018)
- Implement Gloas state. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15611)
- Add initial configs for the state-diff feature. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15903)
- Add kv functions for the state-diff feature. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15903)
- Add supported version for fork versions. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16030)
- prometheus metric `gossip_attestation_verification_milliseconds` to track attestation gossip topic validation latency. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15785)
- Integrate state-diff into `State()`. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16033)
- Implement Gloas fork support in consensus-types/blocks with factory methods, getters, setters, and proto handling. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15618)
- Integrate state-diff into `HasState()`. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16045)
- Added `--semi-supernode` flag to custody half of a super node's datacolumn requirements but allowing for reconstruction for blob retrieval. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16029)
- Data column backfill. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15580)
- Backfill metrics for columns: backfill_data_column_sidecar_downloaded, backfill_data_column_sidecar_downloaded_bytes, backfill_batch_columns_download_ms, backfill_batch_columns_verify_ms. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15580)
- prometheus summary `gossip_data_column_sidecar_arrival_milliseconds` to track data column sidecar arrival latency since slot start. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16099)
### Changed
- Improve readability in slashing import and remove duplicated code. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15957)
- Use dependent root instead of target when possible. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15996)
- Changed `--subscribe-all-data-subnets` flag to `--supernode` and aliased `--subscribe-all-data-subnets` for existing users. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16012)
- Use explicit slot component timing configs. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15999)
- Downgraded log level from INFO to DEBUG on PrepareBeaconProposer updated fee recipients. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15998)
- Change the logging behaviour of Updated fee recipients to only log count of validators at Debug level and all validator indices at Trace level. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15998)
- Stop emitting payload attribute events during late block handling when we are not proposing the next slot. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16026)
- Initialize the `ExecutionRequests` field in gossip block map. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16047)
- Avoid redundant WithHttpEndpoint when JWT is provided. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16032)
- Removed dead slot parameter from blobCacheEntry.filter. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16021)
- Added log prefix to the `genesis` package. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16075)
- Added log prefix to the `params` package. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16075)
- `WithGenesisValidatorsRoot`: Use camelCase for log field param. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16075)
- Move `Origin checkpoint found in db` from WARN to INFO, since it is the expected behaviour. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16075)
- backfill metrics that changed name and/or histogram buckets: backfill_batch_time_verify -> backfill_batch_verify_ms, backfill_batch_time_waiting -> backfill_batch_waiting_ms, backfill_batch_time_roundtrip -> backfill_batch_roundtrip_ms, backfill_blocks_bytes_downloaded -> backfill_blocks_downloaded_bytes, backfill_batch_time_verify -> backfill_batch_verify_ms, backfill_batch_blocks_time_download -> backfill_batch_blocks_download_ms, backfill_batch_blobs_time_download -> backfill_batch_blobs_download_ms, backfill_blobs_bytes_downloaded -> backfill_blocks_downloaded_bytes. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15580)
- Move the "Not enough connected peers" (for a given subnet) from WARN to DEBUG. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16087)
- `blobsDataFromStoredDataColumns`: Ask the use to use the `--supernode` flag and shorten the error mesage. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16097)
- Introduced flag `--ignore-unviable-attestations` (replaces and deprecates `--disable-last-epoch-targets`) to drop attestations whose target state is not viable; default remains to process them unless explicitly enabled. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16094)
### Removed
- Remove validator cross-client from end-to-end tests. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16025)
- `NUMBER_OF_COLUMNS` configuration (not in the specification any more, replaced by a preset). [[PR]](https://github.com/prysmaticlabs/prysm/pull/16073)
- `MAX_CELLS_IN_EXTENDED_MATRIX` configuration (not in the specification any more). [[PR]](https://github.com/prysmaticlabs/prysm/pull/16073)
### Fixed
- Nil check for block if it doesn't exist in the DB in fetchOriginSidecars. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16006)
- Fix proposals progress bar count [#16020](https://github.com/OffchainLabs/prysm/pull/16020). [[PR]](https://github.com/prysmaticlabs/prysm/pull/16020)
- Move `BlockGossipReceived` event to the end of gossip validation. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16031)
- Fix state diff repetitive anchor slot bug. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16037)
- Check the JWT secret length is exactly 256 bits (32 bytes) as per Engine API specification. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15939)
- http_error_count now matches the other cases by listing the endpoint name rather than the actual URL requested. This improves metrics cardinality. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16055)
- Fix array out of bounds in static analyzer. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16058)
- fixes E2E tests to be able to start from Electra genesis fork or future forks. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16048)
- Use head state to validate attestations for old blocks if they are compatible. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16095)
## [v7.0.1](https://github.com/prysmaticlabs/prysm/compare/v7.0.0...v7.0.1) - 2025-12-08
This patch release contains 4 cherry-picked changes to address the mainnet attestation processing issue from 2025-12-04. Operators are encouraged to update to this release as soon as practical. As of this release, the feature flag `--disable-last-epoch-targets` has been deprecated and can be safely removed from your node configuration.
A post mortem doc with full details is expected to be published later this week.
### Changed
- Move the "Not enough connected peers" (for a given subnet) from WARN to DEBUG. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16087)
- Use dependent root instead of target when possible. [[PR]](https://github.com/prysmaticlabs/prysm/pull/15996)
- Introduced flag `--ignore-unviable-attestations` (replaces and deprecates `--disable-last-epoch-targets`) to drop attestations whose target state is not viable; default remains to process them unless explicitly enabled. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16094)
### Fixed
- Use head state to validate attestations for old blocks if they are compatible. [[PR]](https://github.com/prysmaticlabs/prysm/pull/16095)
## [v7.0.0](https://github.com/prysmaticlabs/prysm/compare/v6.1.4...v7.0.0) - 2025-11-10
This is our initial mainnet release for the Ethereum mainnet Fulu fork on December 3rd, 2025. All operators MUST update to v7.0.0 or later release prior to the fulu fork epoch `411392`. See the [Ethereum Foundation blog post](https://blog.ethereum.org/2025/11/06/fusaka-mainnet-announcement) for more information on Fulu.

View File

@@ -60,7 +60,7 @@ func Eth1DataHasEnoughSupport(beaconState state.ReadOnlyBeaconState, data *ethpb
voteCount := uint64(0)
for _, vote := range beaconState.Eth1DataVotes() {
if AreEth1DataEqual(vote, data.Copy()) {
if AreEth1DataEqual(vote, data) {
voteCount++
}
}

View File

@@ -0,0 +1,95 @@
# Graffiti Version Info Implementation
## Summary
Add automatic EL+CL version info to block graffiti following [ethereum/execution-apis#517](https://github.com/ethereum/execution-apis/pull/517). Uses the [flexible standard](https://hackmd.io/@wmoBhF17RAOH2NZ5bNXJVg/BJX2c9gja) to pack client info into leftover space after user graffiti.
More details: https://github.com/ethereum/execution-apis/blob/main/src/engine/identification.md
## Implementation
### Core Component: GraffitiInfo Struct
Thread-safe struct holding version information:
```go
const clCode = "PR"
type GraffitiInfo struct {
mu sync.RWMutex
userGraffiti string // From --graffiti flag (set once at startup)
clCommit string // From version.GetCommitPrefix() helper function
elCode string // From engine_getClientVersionV1
elCommit string // From engine_getClientVersionV1
}
```
### Flow
1. **Startup**: Parse flags, create GraffitiInfo with user graffiti and CL info.
2. **Wiring**: Pass struct to both execution service and RPC validator server
3. **Runtime**: Execution service goroutine periodically calls `engine_getClientVersionV1` and updates EL fields
4. **Block Proposal**: RPC validator server calls `GenerateGraffiti()` to get formatted graffiti
### Flexible Graffiti Format
Packs as much client info as space allows (after user graffiti):
| Available Space | Format | Example |
|----------------|--------|---------|
| ≥12 bytes | `EL(2)+commit(4)+CL(2)+commit(4)+user` | `GE168dPR63afBob` |
| 8-11 bytes | `EL(2)+commit(2)+CL(2)+commit(2)+user` | `GE16PR63my node here` |
| 4-7 bytes | `EL(2)+CL(2)+user` | `GEPRthis is my graffiti msg` |
| 2-3 bytes | `EL(2)+user` | `GEalmost full graffiti message` |
| <2 bytes | user only | `full 32 byte user graffiti here` |
```go
func (g *GraffitiInfo) GenerateGraffiti() [32]byte {
available := 32 - len(userGraffiti)
if elCode == "" {
elCommit2 = elCommit4 = ""
}
switch {
case available >= 12:
return elCode + elCommit4 + clCode + clCommit4 + userGraffiti
case available >= 8:
return elCode + elCommit2 + clCode + clCommit2 + userGraffiti
case available >= 4:
return elCode + clCode + userGraffiti
case available >= 2:
return elCode + userGraffiti
default:
return userGraffiti
}
}
```
### Update Logic
Single testable function in execution service:
```go
func (s *Service) updateGraffitiInfo() {
versions, err := s.GetClientVersion(ctx)
if err != nil {
return // Keep last good value
}
if len(versions) == 1 {
s.graffitiInfo.UpdateFromEngine(versions[0].Code, versions[0].Commit)
}
}
```
Goroutine calls this on `slot % 8 == 4` timing (4 times per epoch, avoids slot boundaries).
### Files Changes Required
**New:**
- `beacon-chain/execution/graffiti_info.go` - The struct and methods
- `beacon-chain/execution/graffiti_info_test.go` - Unit tests
- `runtime/version/version.go` - Add `GetCommitPrefix()` helper that extracts first 4 hex chars from the git commit injected via Bazel ldflags at build time
**Modified:**
- `beacon-chain/execution/service.go` - Add goroutine + updateGraffitiInfo()
- `beacon-chain/execution/engine_client.go` - Add GetClientVersion() method that does engine call
- `beacon-chain/rpc/.../validator/proposer.go` - Call GenerateGraffiti()
- `beacon-chain/node/node.go` - Wire GraffitiInfo to services
### Testing Strategy
- Unit test GraffitiInfo methods (priority logic, thread safety)
- Unit test updateGraffitiInfo() with mocked engine client

View File

@@ -664,6 +664,7 @@ func (b *BeaconNode) registerP2P(cliCtx *cli.Context) error {
DenyListCIDR: slice.SplitCommaSeparated(cliCtx.StringSlice(cmd.P2PDenyList.Name)),
IPColocationWhitelist: colocationWhitelist,
EnableUPnP: cliCtx.Bool(cmd.EnableUPnPFlag.Name),
EnableAutoNAT: cliCtx.Bool(cmd.EnableAutoNATFlag.Name),
StateNotifier: b,
DB: b.db,
StateGen: b.stateGen,

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",
@@ -51,6 +53,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",
@@ -92,6 +95,7 @@ go_library(
"@com_github_libp2p_go_libp2p//core/connmgr:go_default_library",
"@com_github_libp2p_go_libp2p//core/control:go_default_library",
"@com_github_libp2p_go_libp2p//core/crypto:go_default_library",
"@com_github_libp2p_go_libp2p//core/event:go_default_library",
"@com_github_libp2p_go_libp2p//core/host:go_default_library",
"@com_github_libp2p_go_libp2p//core/network:go_default_library",
"@com_github_libp2p_go_libp2p//core/peer:go_default_library",
@@ -101,10 +105,8 @@ go_library(
"@com_github_libp2p_go_libp2p//p2p/security/noise:go_default_library",
"@com_github_libp2p_go_libp2p//p2p/transport/quic:go_default_library",
"@com_github_libp2p_go_libp2p//p2p/transport/tcp:go_default_library",
"@com_github_libp2p_go_libp2p_mplex//:go_default_library",
"@com_github_libp2p_go_libp2p_pubsub//:go_default_library",
"@com_github_libp2p_go_libp2p_pubsub//pb:go_default_library",
"@com_github_libp2p_go_mplex//:go_default_library",
"@com_github_multiformats_go_multiaddr//:go_default_library",
"@com_github_multiformats_go_multiaddr//net:go_default_library",
"@com_github_patrickmn_go_cache//:go_default_library",
@@ -115,6 +117,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",
],
)
@@ -128,6 +131,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",
@@ -154,9 +159,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",
@@ -194,6 +201,7 @@ go_test(
"@com_github_libp2p_go_libp2p//:go_default_library",
"@com_github_libp2p_go_libp2p//core/connmgr:go_default_library",
"@com_github_libp2p_go_libp2p//core/crypto:go_default_library",
"@com_github_libp2p_go_libp2p//core/event:go_default_library",
"@com_github_libp2p_go_libp2p//core/host:go_default_library",
"@com_github_libp2p_go_libp2p//core/network:go_default_library",
"@com_github_libp2p_go_libp2p//core/peer:go_default_library",
@@ -203,8 +211,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

@@ -60,7 +60,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
@@ -106,6 +109,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.
@@ -116,7 +120,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(
@@ -131,7 +135,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")
}
@@ -153,13 +157,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.
@@ -173,7 +178,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(
@@ -187,7 +192,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")
}
@@ -205,7 +210,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)
}
@@ -233,6 +238,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.
@@ -243,7 +249,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 {
@@ -252,7 +258,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")
}
@@ -264,7 +270,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)
}
@@ -293,7 +299,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)
@@ -327,7 +333,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)
@@ -386,13 +392,14 @@ func (s *Service) broadcastDataColumnSidecars(ctx context.Context, forkDigest [f
subnet := peerdas.ComputeSubnetForDataColumnSidecar(sidecar.Index)
// Build the topic corresponding to subnet column subnet and this fork digest.
topic := dataColumnSubnetToTopic(subnet, forkDigest)
topic := DataColumnSubnetTopic(forkDigest, subnet)
// Compute the wrapped subnet index.
wrappedSubIdx := subnet + dataColumnSubnetVal
// Find peers if needed.
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
@@ -487,20 +494,16 @@ 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
}
@@ -525,34 +528,10 @@ func (s *Service) broadcastObject(ctx context.Context, obj ssz.Marshaler, topic
iid := int64(id)
span = trace.AddMessageSendEvent(span, iid, messageLen /*uncompressed*/, messageLen /*compressed*/)
}
if err := s.PublishToTopic(ctx, topic+s.Encoding().ProtocolSuffix(), buf.Bytes()); err != nil {
if err := s.PublishToTopic(ctx, topic, buf.Bytes()); err != nil {
err := errors.Wrap(err, "could not publish message")
tracing.AnnotateError(span, err)
return err
}
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"
"google.golang.org/protobuf/proto"
@@ -109,6 +110,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
@@ -121,7 +123,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{
@@ -130,7 +132,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{
@@ -139,12 +141,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")
}
}
@@ -173,14 +175,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)
@@ -226,6 +226,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()
@@ -251,8 +252,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,
@@ -261,11 +263,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,
@@ -278,18 +290,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.
@@ -324,7 +340,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),
@@ -340,7 +356,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),
@@ -353,14 +369,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)
@@ -431,14 +445,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)
@@ -508,14 +520,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)
@@ -575,10 +585,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)
@@ -651,10 +660,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)
@@ -702,7 +710,6 @@ func TestService_BroadcastDataColumn(t *testing.T) {
const (
port = 2000
columnIndex = 12
topicFormat = DataColumnSubnetTopicFormat
)
ctx := t.Context()
@@ -760,7 +767,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

@@ -28,6 +28,7 @@ const (
type Config struct {
NoDiscovery bool
EnableUPnP bool
EnableAutoNAT bool
StaticPeerID bool
DisableLivenessCheck bool
StaticPeers []string

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,252 @@
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 = 1 * time.Second
// 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
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
go g.dialLoop()
})
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
}
}
}
// 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
}
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,523 @@
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())
}
})
}
}
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) 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,546 @@
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"
)
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) 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) getPeersForTopic(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
once sync.Once
}
// cleanupInterval controls how frequently we sweep crawled peers and prune
// those that are no longer useful.
const cleanupInterval = 5 * 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, 4*maxConcurrentPings)
g.pingSemaphore = semaphore.NewWeighted(maxConcurrentPings)
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.getPeersForTopic(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()
})
return nil
}
func (g *GossipPeerCrawler) pingLoop() {
for {
select {
case node := <-g.pingCh:
if err := g.pingSemaphore.Acquire(g.ctx, 1); err != nil {
return
}
go func(node *enode.Node) {
defer g.pingSemaphore.Release(1)
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.ctx.Done():
return
}
}
}
func (g *GossipPeerCrawler) crawl() {
log.Debug("Bitcoin")
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()
}()
log.Debug("ABCDEFG")
for iterator.Next() {
log.Debug("Ethereum")
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())
}
}
}
// 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,35 @@
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
}
// 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
}

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"
@@ -98,11 +98,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

@@ -4,20 +4,17 @@ import (
"crypto/ecdsa"
"fmt"
"net"
"time"
"github.com/OffchainLabs/prysm/v7/config/features"
ecdsaprysm "github.com/OffchainLabs/prysm/v7/crypto/ecdsa"
"github.com/OffchainLabs/prysm/v7/runtime/version"
"github.com/libp2p/go-libp2p"
mplex "github.com/libp2p/go-libp2p-mplex"
"github.com/libp2p/go-libp2p/core/network"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/libp2p/go-libp2p/p2p/net/connmgr"
"github.com/libp2p/go-libp2p/p2p/security/noise"
libp2pquic "github.com/libp2p/go-libp2p/p2p/transport/quic"
libp2ptcp "github.com/libp2p/go-libp2p/p2p/transport/tcp"
gomplex "github.com/libp2p/go-mplex"
ma "github.com/multiformats/go-multiaddr"
"github.com/pkg/errors"
)
@@ -110,7 +107,6 @@ func (s *Service) buildOptions(ip net.IP, priKey *ecdsa.PrivateKey) ([]libp2p.Op
libp2p.ConnectionGater(s),
libp2p.Transport(libp2ptcp.NewTCPTransport),
libp2p.DefaultMuxers,
libp2p.Muxer("/mplex/6.7.0", mplex.DefaultTransport),
libp2p.Security(noise.ID, noise.New),
libp2p.Ping(false), // Disable Ping Service.
}
@@ -162,6 +158,10 @@ func (s *Service) buildOptions(ip net.IP, priKey *ecdsa.PrivateKey) ([]libp2p.Op
options = append(options, libp2p.ResourceManager(&network.NullResourceManager{}))
}
if cfg.EnableAutoNAT {
options = append(options, libp2p.EnableAutoNATv2())
}
return options, nil
}
@@ -217,8 +217,3 @@ func privKeyOption(privkey *ecdsa.PrivateKey) libp2p.Option {
return cfg.Apply(libp2p.Identity(ifaceKey))
}
}
// Configures stream timeouts on mplex.
func configureMplex() {
gomplex.ResetStreamTimeout = 5 * time.Second
}

View File

@@ -132,7 +132,6 @@ func TestDefaultMultiplexers(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, protocol.ID("/yamux/1.0.0"), cfg.Muxers[0].ID)
assert.Equal(t, protocol.ID("/mplex/6.7.0"), cfg.Muxers[1].ID)
}
func TestSetConnManagerOption(t *testing.T) {
@@ -188,6 +187,30 @@ func checkLimit(t *testing.T, cm connmgr.ConnManager, expected int) {
}
}
func TestBuildOptions_EnableAutoNAT(t *testing.T) {
params.SetupTestConfigCleanup(t)
p2pCfg := &Config{
UDPPort: 2000,
TCPPort: 3000,
QUICPort: 3000,
EnableAutoNAT: true,
StateNotifier: &mock.MockStateNotifier{},
}
svc := &Service{cfg: p2pCfg}
var err error
svc.privKey, err = privKey(svc.cfg)
require.NoError(t, err)
ipAddr := network.IPAddr()
opts, err := svc.buildOptions(ipAddr, svc.privKey)
require.NoError(t, err)
// Verify that options were built without error when EnableAutoNAT is true.
// The actual AutoNAT v2 behavior is tested by libp2p itself.
var cfg libp2p.Config
err = cfg.Apply(append(opts, libp2p.FallbackDefaults)...)
assert.NoError(t, err)
}
func TestMultiAddressBuilderWithID(t *testing.T) {
testCases := []struct {
name string

View File

@@ -58,7 +58,7 @@ func TestPeerExplicitAdd(t *testing.T) {
resAddress, err := p.Address(id)
require.NoError(t, err)
assert.Equal(t, address, resAddress, "Unexpected address")
assert.Equal(t, address.Equal(resAddress), true, "Unexpected address")
resDirection, err := p.Direction(id)
require.NoError(t, err)
@@ -72,7 +72,7 @@ func TestPeerExplicitAdd(t *testing.T) {
resAddress2, err := p.Address(id)
require.NoError(t, err)
assert.Equal(t, address2, resAddress2, "Unexpected address")
assert.Equal(t, address2.Equal(resAddress2), true, "Unexpected address")
resDirection2, err := p.Direction(id)
require.NoError(t, err)

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"
@@ -28,6 +30,7 @@ import (
"github.com/ethereum/go-ethereum/p2p/enr"
"github.com/libp2p/go-libp2p"
pubsub "github.com/libp2p/go-libp2p-pubsub"
"github.com/libp2p/go-libp2p/core/event"
"github.com/libp2p/go-libp2p/core/host"
"github.com/libp2p/go-libp2p/core/network"
"github.com/libp2p/go-libp2p/core/peer"
@@ -61,6 +64,10 @@ var (
// for the current peer limit status for the time period
// defined below.
pollingPeriod = 6 * time.Second
crawlTimeout = 5 * time.Second
crawlInterval = 1 * time.Second
maxConcurrentDials = int64(256)
)
// Service for managing peer to peer (p2p) networking.
@@ -95,6 +102,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 {
@@ -154,8 +163,6 @@ func NewService(ctx context.Context, cfg *Config) (*Service, error) {
return nil, errors.Wrapf(err, "failed to build p2p options")
}
// Sets mplex timeouts
configureMplex()
h, err := libp2p.New(opts...)
if err != nil {
return nil, errors.Wrapf(err, "failed to create p2p host")
@@ -163,7 +170,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 +248,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
@@ -259,6 +285,14 @@ func (s *Service) Start() {
// current epoch.
s.RefreshPersistentSubnets()
if s.cfg.EnableAutoNAT {
if err := s.subscribeReachabilityEvents(); err != nil {
log.WithError(err).Error("Failed to subscribe to AutoNAT v2 reachability events")
} else {
log.Info("AutoNAT v2 enabled for address reachability detection")
}
}
// Periodic functions.
async.RunEvery(s.ctx, params.BeaconConfig().TtfbTimeoutDuration(), func() {
ensurePeerConnections(s.ctx, s.host, s.peers, relayNodes...)
@@ -311,12 +345,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 +604,118 @@ 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 (s *Service) subscribeReachabilityEvents() error {
sub, err := s.host.EventBus().Subscribe(new(event.EvtHostReachableAddrsChanged))
if err != nil {
return fmt.Errorf("subscribing to reachability events: %w", err)
}
go func() {
defer func() {
if err := sub.Close(); err != nil {
log.WithError(err).Debug("Failed to close reachability event subscription")
}
}()
for {
select {
case <-s.ctx.Done():
return
case ev := <-sub.Out():
if event, ok := ev.(event.EvtHostReachableAddrsChanged); ok {
log.WithFields(logrus.Fields{
"reachable": multiaddrsToStrings(event.Reachable),
"unreachable": multiaddrsToStrings(event.Unreachable),
"unknown": multiaddrsToStrings(event.Unknown),
}).Info("Address reachability changed")
}
}
}
}()
return nil
}
func multiaddrsToStrings(addrs []multiaddr.Multiaddr) []string {
strs := make([]string, len(addrs))
for i, a := range addrs {
strs[i] = a.String()
}
return strs
}
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

@@ -21,6 +21,7 @@ import (
"github.com/OffchainLabs/prysm/v7/testing/require"
prysmTime "github.com/OffchainLabs/prysm/v7/time"
"github.com/libp2p/go-libp2p"
"github.com/libp2p/go-libp2p/core/event"
"github.com/libp2p/go-libp2p/core/host"
"github.com/libp2p/go-libp2p/core/peer"
noise "github.com/libp2p/go-libp2p/p2p/security/noise"
@@ -35,7 +36,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
}
@@ -400,3 +402,58 @@ func TestService_connectWithPeer(t *testing.T) {
})
}
}
func TestService_SubscribeReachabilityEvents(t *testing.T) {
hook := logTest.NewGlobal()
ctx := t.Context()
h, _, _ := createHost(t, 0)
defer func() {
if err := h.Close(); err != nil {
t.Fatal(err)
}
}()
// Create service with the host
s := &Service{
ctx: ctx,
host: h,
cfg: &Config{EnableAutoNAT: true},
}
// Get an emitter for the reachability event
emitter, err := h.EventBus().Emitter(new(event.EvtHostReachableAddrsChanged))
require.NoError(t, err)
defer func() {
if err := emitter.Close(); err != nil {
t.Fatal(err)
}
}()
// Subscribe to reachability events
require.NoError(t, s.subscribeReachabilityEvents())
// Create test multiaddrs for each reachability state
reachableAddr, err := multiaddr.NewMultiaddr("/ip4/192.168.1.1/tcp/9000")
require.NoError(t, err)
unreachableAddr, err := multiaddr.NewMultiaddr("/ip4/10.0.0.1/tcp/9001")
require.NoError(t, err)
unknownAddr, err := multiaddr.NewMultiaddr("/ip4/172.16.0.1/tcp/9002")
require.NoError(t, err)
// Emit a reachability event with all address types
err = emitter.Emit(event.EvtHostReachableAddrsChanged{
Reachable: []multiaddr.Multiaddr{reachableAddr},
Unreachable: []multiaddr.Multiaddr{unreachableAddr},
Unknown: []multiaddr.Multiaddr{unknownAddr},
})
require.NoError(t, err)
// Wait for the event to be processed
time.Sleep(100 * time.Millisecond)
// Verify the log message contains all addresses
require.LogsContain(t, hook, "Address reachability changed")
require.LogsContain(t, hook, "/ip4/192.168.1.1/tcp/9000")
require.LogsContain(t, hook, "/ip4/10.0.0.1/tcp/9001")
require.LogsContain(t, hook, "/ip4/172.16.0.1/tcp/9002")
}

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,16 @@ 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
}
// AddDisconnectionHandler -- fake.
func (*FakeP2P) AddDisconnectionHandler(_ func(ctx context.Context, id peer.ID) error) {
}
@@ -70,11 +80,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 +98,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.
@@ -183,11 +184,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)
}
@@ -279,6 +276,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
@@ -419,9 +419,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.
@@ -558,3 +557,40 @@ 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{}
}
// 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

@@ -82,8 +82,8 @@ type StateRootNotFoundError struct {
}
// NewStateRootNotFoundError creates a new error instance.
func NewStateRootNotFoundError(stateRootsSize int) StateNotFoundError {
return StateNotFoundError{
func NewStateRootNotFoundError(stateRootsSize int) StateRootNotFoundError {
return StateRootNotFoundError{
message: fmt.Sprintf("state root not found in the last %d state roots", stateRootsSize),
}
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/OffchainLabs/prysm/v7/beacon-chain/state"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/encoding/bytesutil"
"github.com/OffchainLabs/prysm/v7/monitoring/tracing/trace"
"github.com/sirupsen/logrus"
@@ -37,76 +38,84 @@ func (s *State) MigrateToCold(ctx context.Context, fRoot [32]byte) error {
return nil
}
// Start at previous finalized slot, stop at current finalized slot (it will be handled in the next migration).
// If the slot is on archived point, save the state of that slot to the DB.
for slot := oldFSlot; slot < fSlot; slot++ {
// Calculate the first archived point slot >= oldFSlot (but > 0).
// This avoids iterating through every slot and only visits archived points directly.
var startSlot primitives.Slot
if oldFSlot == 0 {
startSlot = s.slotsPerArchivedPoint
} else {
// Round up to the next archived point
startSlot = (oldFSlot + s.slotsPerArchivedPoint - 1) / s.slotsPerArchivedPoint * s.slotsPerArchivedPoint
}
// Start at the first archived point after old finalized slot, stop before current finalized slot.
// Jump directly between archived points.
for slot := startSlot; slot < fSlot; slot += s.slotsPerArchivedPoint {
if ctx.Err() != nil {
return ctx.Err()
}
if slot%s.slotsPerArchivedPoint == 0 && slot != 0 {
cached, exists, err := s.epochBoundaryStateCache.getBySlot(slot)
cached, exists, err := s.epochBoundaryStateCache.getBySlot(slot)
if err != nil {
return fmt.Errorf("could not get epoch boundary state for slot %d", slot)
}
var aRoot [32]byte
var aState state.BeaconState
// When the epoch boundary state is not in cache due to skip slot scenario,
// we have to regenerate the state which will represent epoch boundary.
// By finding the highest available block below epoch boundary slot, we
// generate the state for that block root.
if exists {
aRoot = cached.root
aState = cached.state
} else {
_, roots, err := s.beaconDB.HighestRootsBelowSlot(ctx, slot)
if err != nil {
return fmt.Errorf("could not get epoch boundary state for slot %d", slot)
return err
}
var aRoot [32]byte
var aState state.BeaconState
// When the epoch boundary state is not in cache due to skip slot scenario,
// we have to regenerate the state which will represent epoch boundary.
// By finding the highest available block below epoch boundary slot, we
// generate the state for that block root.
if exists {
aRoot = cached.root
aState = cached.state
} else {
_, roots, err := s.beaconDB.HighestRootsBelowSlot(ctx, slot)
// Given the block has been finalized, the db should not have more than one block in a given slot.
// We should error out when this happens.
if len(roots) != 1 {
return errUnknownBlock
}
aRoot = roots[0]
// There's no need to generate the state if the state already exists in the DB.
// We can skip saving the state.
if !s.beaconDB.HasState(ctx, aRoot) {
aState, err = s.StateByRoot(ctx, aRoot)
if err != nil {
return err
}
// Given the block has been finalized, the db should not have more than one block in a given slot.
// We should error out when this happens.
if len(roots) != 1 {
return errUnknownBlock
}
aRoot = roots[0]
// There's no need to generate the state if the state already exists in the DB.
// We can skip saving the state.
if !s.beaconDB.HasState(ctx, aRoot) {
aState, err = s.StateByRoot(ctx, aRoot)
if err != nil {
return err
}
}
}
if s.beaconDB.HasState(ctx, aRoot) {
// If you are migrating a state and its already part of the hot state cache saved to the db,
// you can just remove it from the hot state cache as it becomes redundant.
s.saveHotStateDB.lock.Lock()
roots := s.saveHotStateDB.blockRootsOfSavedStates
for i := range roots {
if aRoot == roots[i] {
s.saveHotStateDB.blockRootsOfSavedStates = append(roots[:i], roots[i+1:]...)
// There shouldn't be duplicated roots in `blockRootsOfSavedStates`.
// Break here is ok.
break
}
}
s.saveHotStateDB.lock.Unlock()
continue
}
if err := s.beaconDB.SaveState(ctx, aState, aRoot); err != nil {
return err
}
log.WithFields(
logrus.Fields{
"slot": aState.Slot(),
"root": hex.EncodeToString(bytesutil.Trunc(aRoot[:])),
}).Info("Saved state in DB")
}
if s.beaconDB.HasState(ctx, aRoot) {
// If you are migrating a state and its already part of the hot state cache saved to the db,
// you can just remove it from the hot state cache as it becomes redundant.
s.saveHotStateDB.lock.Lock()
roots := s.saveHotStateDB.blockRootsOfSavedStates
for i := range roots {
if aRoot == roots[i] {
s.saveHotStateDB.blockRootsOfSavedStates = append(roots[:i], roots[i+1:]...)
// There shouldn't be duplicated roots in `blockRootsOfSavedStates`.
// Break here is ok.
break
}
}
s.saveHotStateDB.lock.Unlock()
continue
}
if err := s.beaconDB.SaveState(ctx, aState, aRoot); err != nil {
return err
}
log.WithFields(
logrus.Fields{
"slot": aState.Slot(),
"root": hex.EncodeToString(bytesutil.Trunc(aRoot[:])),
}).Info("Saved state in DB")
}
// Update finalized info in memory.

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",
@@ -144,7 +151,6 @@ go_library(
"@com_github_libp2p_go_libp2p//core/peer:go_default_library",
"@com_github_libp2p_go_libp2p//core/protocol:go_default_library",
"@com_github_libp2p_go_libp2p_pubsub//:go_default_library",
"@com_github_libp2p_go_mplex//:go_default_library",
"@com_github_patrickmn_go_cache//:go_default_library",
"@com_github_pkg_errors//:go_default_library",
"@com_github_prometheus_client_golang//prometheus:go_default_library",
@@ -175,6 +181,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 +208,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 +295,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

@@ -10,7 +10,6 @@ import (
"github.com/OffchainLabs/prysm/v7/config/params"
libp2pcore "github.com/libp2p/go-libp2p/core"
"github.com/libp2p/go-libp2p/core/network"
multiplex "github.com/libp2p/go-mplex"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
@@ -148,7 +147,7 @@ func closeStreamAndWait(stream network.Stream, log *logrus.Entry) {
}
func isUnwantedError(err error) bool {
for _, e := range []error{network.ErrReset, multiplex.ErrShutdown, io.EOF, types.ErrIODeadline} {
for _, e := range []error{network.ErrReset, io.EOF, types.ErrIODeadline} {
if errors.Is(err, e) || err.Error() == e.Error() {
return true
}

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

@@ -265,7 +265,7 @@ func (s *Service) processVerifiedAttestation(
if key, err := generateUnaggregatedAttCacheKey(broadcastAtt); err != nil {
log.WithError(err).Error("Failed to generate cache key for attestation tracking")
} else {
s.setSeenUnaggregatedAtt(key)
_ = s.setSeenUnaggregatedAtt(key)
}
valCount, err := helpers.ActiveValidatorCount(ctx, preState, slots.ToEpoch(data.Slot))
@@ -320,7 +320,7 @@ func (s *Service) processAggregate(ctx context.Context, aggregate ethpb.SignedAg
return
}
s.setAggregatorIndexEpochSeen(att.GetData().Target.Epoch, aggregate.AggregateAttestationAndProof().GetAggregatorIndex())
_ = s.setAggregatorIndexEpochSeen(att.GetData().Target.Epoch, aggregate.AggregateAttestationAndProof().GetAggregatorIndex())
if err := s.cfg.p2p.Broadcast(ctx, aggregate); err != nil {
log.WithError(err).Debug("Could not broadcast aggregated attestation")

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,36 @@ func (s *Service) startDiscoveryAndSubscriptions() {
}
// Start the fork watcher.
go s.p2pHandlerControlLoop()
go s.rpcHandlerControlLoop()
// Start the gossipsub controller.
go s.subscriptionController.Start()
// Configure the crawler and dialer with the topic extractor / subnet topics
// provider if available.
crawler := s.cfg.p2p.Crawler()
if crawler == nil {
log.Info("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()
@@ -816,34 +289,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

@@ -137,7 +137,9 @@ func (s *Service) validateAggregateAndProof(ctx context.Context, pid peer.ID, ms
return validationRes, err
}
s.setAggregatorIndexEpochSeen(data.Target.Epoch, m.AggregateAttestationAndProof().GetAggregatorIndex())
if first := s.setAggregatorIndexEpochSeen(data.Target.Epoch, m.AggregateAttestationAndProof().GetAggregatorIndex()); !first {
return pubsub.ValidationIgnore, nil
}
msg.ValidatorData = m
@@ -265,13 +267,19 @@ func (s *Service) hasSeenAggregatorIndexEpoch(epoch primitives.Epoch, aggregator
}
// Set aggregate's aggregator index target epoch as seen.
func (s *Service) setAggregatorIndexEpochSeen(epoch primitives.Epoch, aggregatorIndex primitives.ValidatorIndex) {
// Returns true if this is the first time seeing this aggregator index and epoch.
func (s *Service) setAggregatorIndexEpochSeen(epoch primitives.Epoch, aggregatorIndex primitives.ValidatorIndex) bool {
b := append(bytesutil.Bytes32(uint64(epoch)), bytesutil.Bytes32(uint64(aggregatorIndex))...)
s.seenAggregatedAttestationLock.Lock()
defer s.seenAggregatedAttestationLock.Unlock()
_, seen := s.seenAggregatedAttestationCache.Get(string(b))
if seen {
return false
}
s.seenAggregatedAttestationCache.Add(string(b), true)
return true
}
// This validates the bitfield is correct and aggregator's index in state is within the beacon committee.

View File

@@ -801,3 +801,27 @@ func TestValidateAggregateAndProof_RejectWhenAttEpochDoesntEqualTargetEpoch(t *t
assert.NotNil(t, err)
assert.Equal(t, pubsub.ValidationReject, res)
}
func Test_SetAggregatorIndexEpochSeen(t *testing.T) {
db := dbtest.SetupDB(t)
p := p2ptest.NewTestP2P(t)
r := &Service{
cfg: &config{
p2p: p,
beaconDB: db,
},
seenAggregatedAttestationCache: lruwrpr.New(10),
}
aggIndex := primitives.ValidatorIndex(42)
epoch := primitives.Epoch(7)
require.Equal(t, false, r.hasSeenAggregatorIndexEpoch(epoch, aggIndex))
first := r.setAggregatorIndexEpochSeen(epoch, aggIndex)
require.Equal(t, true, first)
require.Equal(t, true, r.hasSeenAggregatorIndexEpoch(epoch, aggIndex))
second := r.setAggregatorIndexEpochSeen(epoch, aggIndex)
require.Equal(t, false, second)
}

View File

@@ -104,7 +104,8 @@ func (s *Service) validateCommitteeIndexBeaconAttestation(
}
if !s.slasherEnabled {
// Verify this the first attestation received for the participating validator for the slot.
// Verify this the first attestation received for the participating validator for the slot. This verification is here to return early if we've already seen this attestation.
// This verification is carried again later after all other validations to avoid TOCTOU issues.
if s.hasSeenUnaggregatedAtt(attKey) {
return pubsub.ValidationIgnore, nil
}
@@ -228,7 +229,10 @@ func (s *Service) validateCommitteeIndexBeaconAttestation(
Data: eventData,
})
s.setSeenUnaggregatedAtt(attKey)
if first := s.setSeenUnaggregatedAtt(attKey); !first {
// Another concurrent validation processed the same attestation meanwhile
return pubsub.ValidationIgnore, nil
}
// Attach final validated attestation to the message for further pipeline use
msg.ValidatorData = attForValidation
@@ -385,11 +389,16 @@ func (s *Service) hasSeenUnaggregatedAtt(key string) bool {
}
// Set an incoming attestation as seen for the participating validator for the slot.
func (s *Service) setSeenUnaggregatedAtt(key string) {
// Returns false if the attestation was already seen.
func (s *Service) setSeenUnaggregatedAtt(key string) bool {
s.seenUnAggregatedAttestationLock.Lock()
defer s.seenUnAggregatedAttestationLock.Unlock()
_, seen := s.seenUnAggregatedAttestationCache.Get(key)
if seen {
return false
}
s.seenUnAggregatedAttestationCache.Add(key, true)
return true
}
// hasBlockAndState returns true if the beacon node knows about a block and associated state in the

View File

@@ -499,6 +499,10 @@ func TestService_setSeenUnaggregatedAtt(t *testing.T) {
Data: &ethpb.AttestationData{Slot: 2, CommitteeIndex: 0},
AggregationBits: bitfield.Bitlist{0b1001},
}
s3c0a0 := &ethpb.Attestation{
Data: &ethpb.AttestationData{Slot: 3, CommitteeIndex: 0},
AggregationBits: bitfield.Bitlist{0b1001},
}
t.Run("empty cache", func(t *testing.T) {
key := generateKey(t, s0c0a0)
@@ -506,26 +510,39 @@ func TestService_setSeenUnaggregatedAtt(t *testing.T) {
})
t.Run("ok", func(t *testing.T) {
key := generateKey(t, s0c0a0)
s.setSeenUnaggregatedAtt(key)
first := s.setSeenUnaggregatedAtt(key)
assert.Equal(t, true, s.hasSeenUnaggregatedAtt(key))
assert.Equal(t, true, first)
})
t.Run("already seen", func(t *testing.T) {
key := generateKey(t, s3c0a0)
first := s.setSeenUnaggregatedAtt(key)
assert.Equal(t, true, s.hasSeenUnaggregatedAtt(key))
assert.Equal(t, true, first)
first = s.setSeenUnaggregatedAtt(key)
assert.Equal(t, true, s.hasSeenUnaggregatedAtt(key))
assert.Equal(t, false, first)
})
t.Run("different slot", func(t *testing.T) {
key1 := generateKey(t, s1c0a0)
key2 := generateKey(t, s2c0a0)
s.setSeenUnaggregatedAtt(key1)
first := s.setSeenUnaggregatedAtt(key1)
assert.Equal(t, false, s.hasSeenUnaggregatedAtt(key2))
assert.Equal(t, true, first)
})
t.Run("different committee index", func(t *testing.T) {
key1 := generateKey(t, s0c1a0)
key2 := generateKey(t, s0c2a0)
s.setSeenUnaggregatedAtt(key1)
first := s.setSeenUnaggregatedAtt(key1)
assert.Equal(t, false, s.hasSeenUnaggregatedAtt(key2))
assert.Equal(t, true, first)
})
t.Run("different bit", func(t *testing.T) {
key1 := generateKey(t, s0c0a1)
key2 := generateKey(t, s0c0a2)
s.setSeenUnaggregatedAtt(key1)
first := s.setSeenUnaggregatedAtt(key1)
assert.Equal(t, false, s.hasSeenUnaggregatedAtt(key2))
assert.Equal(t, true, first)
})
t.Run("0 bits set is considered not seen", func(t *testing.T) {
a := &ethpb.Attestation{AggregationBits: bitfield.Bitlist{0b1000}}
@@ -576,6 +593,11 @@ func TestService_setSeenUnaggregatedAtt(t *testing.T) {
CommitteeId: 0,
AttesterIndex: 0,
}
s3c0a0 := &ethpb.SingleAttestation{
Data: &ethpb.AttestationData{Slot: 2},
CommitteeId: 0,
AttesterIndex: 0,
}
t.Run("empty cache", func(t *testing.T) {
key := generateKey(t, s0c0a0)
@@ -583,26 +605,39 @@ func TestService_setSeenUnaggregatedAtt(t *testing.T) {
})
t.Run("ok", func(t *testing.T) {
key := generateKey(t, s0c0a0)
s.setSeenUnaggregatedAtt(key)
first := s.setSeenUnaggregatedAtt(key)
assert.Equal(t, true, s.hasSeenUnaggregatedAtt(key))
assert.Equal(t, true, first)
})
t.Run("different slot", func(t *testing.T) {
key1 := generateKey(t, s1c0a0)
key2 := generateKey(t, s2c0a0)
s.setSeenUnaggregatedAtt(key1)
first := s.setSeenUnaggregatedAtt(key1)
assert.Equal(t, false, s.hasSeenUnaggregatedAtt(key2))
assert.Equal(t, true, first)
})
t.Run("already seen", func(t *testing.T) {
key := generateKey(t, s3c0a0)
first := s.setSeenUnaggregatedAtt(key)
assert.Equal(t, true, s.hasSeenUnaggregatedAtt(key))
assert.Equal(t, true, first)
first = s.setSeenUnaggregatedAtt(key)
assert.Equal(t, true, s.hasSeenUnaggregatedAtt(key))
assert.Equal(t, false, first)
})
t.Run("different committee index", func(t *testing.T) {
key1 := generateKey(t, s0c1a0)
key2 := generateKey(t, s0c2a0)
s.setSeenUnaggregatedAtt(key1)
first := s.setSeenUnaggregatedAtt(key1)
assert.Equal(t, false, s.hasSeenUnaggregatedAtt(key2))
assert.Equal(t, true, first)
})
t.Run("different attester", func(t *testing.T) {
key1 := generateKey(t, s0c0a1)
key2 := generateKey(t, s0c0a2)
s.setSeenUnaggregatedAtt(key1)
first := s.setSeenUnaggregatedAtt(key1)
assert.Equal(t, false, s.hasSeenUnaggregatedAtt(key2))
assert.Equal(t, true, first)
})
t.Run("single attestation is considered not seen", func(t *testing.T) {
a := &ethpb.AttestationElectra{}

View File

@@ -1,3 +0,0 @@
### Changed
- Removed dead slot parameter from blobCacheEntry.filter

View File

@@ -1,3 +0,0 @@
## Changed
- Avoid redundant WithHttpEndpoint when JWT is provided

View File

@@ -1,3 +0,0 @@
### Fixed
- Fix proposals progress bar count [#16020](https://github.com/OffchainLabs/prysm/pull/16020)

View File

@@ -0,0 +1,3 @@
## Fixed
- incorrect constructor return type [#16084](https://github.com/OffchainLabs/prysm/pull/16084)

View File

@@ -0,0 +1,3 @@
### Added
- Add support for detecting and logging per address reachability via libp2p AutoNAT v2.

View File

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

View File

@@ -1,3 +0,0 @@
### Added
- Integrate state-diff into `HasState()`.

View File

@@ -1,3 +0,0 @@
### Ignored
- Refactor finding slot by block root using state summary and block to its own function.

View File

@@ -1,3 +0,0 @@
### Fixed
- Fix state diff repetitive anchor slot bug.

View File

@@ -1,4 +0,0 @@
### Added
- Add initial configs for the state-diff feature.
- Add kv functions for the state-diff feature.

View File

@@ -1,3 +0,0 @@
### Added
- Integrate state-diff into `State()`.

View File

@@ -1,3 +0,0 @@
### Added
- add fulu support to light client processing.

View File

@@ -1,2 +0,0 @@
### Added
- prometheus metric `gossip_attestation_verification_milliseconds` to track attestation gossip topic validation latency.

View File

@@ -1,4 +0,0 @@
### Changed
- Downgraded log level from INFO to DEBUG on PrepareBeaconProposer updated fee recipients.
- Change the logging behaviour of Updated fee recipients to only log count of validators at Debug level and all validator indices at Trace level.

View File

@@ -1,2 +0,0 @@
### Ignored
- Add osaka fork timestamp derivation to interop genesis

View File

@@ -1,3 +0,0 @@
### Fixed
- fixes E2E tests to be able to start from Electra genesis fork or future forks

View File

@@ -1,3 +0,0 @@
### Ignored
- optimization to remove cell and blob proof computation on blob rest api.

View File

@@ -1,2 +0,0 @@
### Added
- Added `--semi-supernode` flag to custody half of a super node's datacolumn requirements but allowing for reconstruction for blob retrieval

View File

@@ -1,3 +0,0 @@
### Changed
- Changed `--subscribe-all-data-subnets` flag to `--supernode` and aliased `--subscribe-all-data-subnets` for existing users.

View File

@@ -1,7 +0,0 @@
### Added
- Data column backfill.
- Backfill metrics for columns: backfill_data_column_sidecar_downloaded, backfill_data_column_sidecar_downloaded_bytes, backfill_batch_columns_download_ms, backfill_batch_columns_verify_ms.
### Changed
- backfill metrics that changed name and/or histogram buckets: backfill_batch_time_verify -> backfill_batch_verify_ms, backfill_batch_time_waiting -> backfill_batch_waiting_ms, backfill_batch_time_roundtrip -> backfill_batch_roundtrip_ms, backfill_blocks_bytes_downloaded -> backfill_blocks_downloaded_bytes, backfill_batch_time_verify -> backfill_batch_verify_ms, backfill_batch_blocks_time_download -> backfill_batch_blocks_download_ms, backfill_batch_blobs_time_download -> backfill_batch_blobs_download_ms, backfill_blobs_bytes_downloaded -> backfill_blocks_downloaded_bytes,

View File

@@ -1,3 +0,0 @@
### Changed
- Stop emitting payload attribute events during late block handling when we are not proposing the next slot

View File

@@ -1,3 +0,0 @@
### Changed
- `blobsDataFromStoredDataColumns`: Ask the use to use the `--supernode` flag and shorten the error mesage.

View File

@@ -1,6 +0,0 @@
### Changed
- Added log prefix to the `genesis` package.
- Added log prefix to the `params` package.
- `WithGenesisValidatorsRoot`: Use camelCase for log field param.
- Move `Origin checkpoint found in db` from WARN to INFO, since it is the expected behaviour.

View File

@@ -1,3 +0,0 @@
### Changed
- Move the "Not enough connected peers" (for a given subnet) from WARN to DEBUG

View File

@@ -1,4 +0,0 @@
### Removed
- `NUMBER_OF_COLUMNS` configuration (not in the specification any more, replaced by a preset)
- `MAX_CELLS_IN_EXTENDED_MATRIX` configuration (not in the specification any more)

View File

@@ -0,0 +1,2 @@
### Ignored
- Added test requirement to `PULL_REQUEST_TEMPLATE.md`

View File

@@ -1,2 +0,0 @@
### Fixed
- Check the JWT secret length is exactly 256 bits (32 bytes) as per Engine API specification

View File

@@ -1,2 +0,0 @@
### Changed
- Improve readability in slashing import and remove duplicated code

View File

@@ -0,0 +1,3 @@
### Fixed
- Fixed possible race when validating two attestations at the same time.

View File

@@ -1,3 +0,0 @@
### Fixed
- Fix array out of bounds in static analyzer.

View File

@@ -1,3 +0,0 @@
### Changed
- Use dependent root instead of target when possible.

View File

@@ -1,3 +0,0 @@
### Fixed
- Use head state to validate attestations for old blocks if they are compatible.

View File

@@ -1,3 +0,0 @@
### Ignored
- Copied deleted dependency `github.com/tyler-smith/go-bip39` to the third_party directory and updated prysm to use that.

View File

@@ -1,3 +0,0 @@
### Fixed
- http_error_count now matches the other cases by listing the endpoint name rather than the actual URL requested. This improves metrics cardinality.

View File

@@ -1,4 +0,0 @@
### Ignored
- Updated golang.org/x/tools
- Introduced modernize static analyzers to nogo

View File

@@ -1,3 +0,0 @@
### Ignored
- Updated CHANGELOG.md with release notes from v7.0.0

3
changelog/pvl-v7.0.1.md Normal file
View File

@@ -0,0 +1,3 @@
### Ignored
- Updated CHANGELOG.md for v7.0.1 patch release

3
changelog/pvl-v7.1.0.md Normal file
View File

@@ -0,0 +1,3 @@
### Ignored
- Changelog for v7.1.0

View File

@@ -1,3 +0,0 @@
### Changed
- Initialize the `ExecutionRequests` field in gossip block map.

View File

@@ -1,3 +0,0 @@
### Fixed
- Move `BlockGossipReceived` event to the end of gossip validation.

View File

@@ -1,3 +0,0 @@
### Removed
- Remove validator cross-client from end-to-end tests.

View File

@@ -1,3 +0,0 @@
### Ignored
- Replace fixed sleep delays with active polling in prometheus service test to improve test reliability.

Some files were not shown because too many files have changed in this diff Show More