mirror of
https://github.com/OffchainLabs/prysm.git
synced 2026-01-09 21:38:05 -05:00
Compare commits
103 Commits
eas-metric
...
manu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b57c35a468 | ||
|
|
08f117f04f | ||
|
|
0b6365781f | ||
|
|
0c994445ea | ||
|
|
77c32203af | ||
|
|
743e6bab07 | ||
|
|
451d2a8bc5 | ||
|
|
6508bdfa9a | ||
|
|
d2bf512f36 | ||
|
|
e62fe66b0a | ||
|
|
08fff3dec4 | ||
|
|
75a3d45470 | ||
|
|
bbd2d4da0f | ||
|
|
378468c1ec | ||
|
|
25d375dbe0 | ||
|
|
2d5f3112c8 | ||
|
|
1d49a2a88d | ||
|
|
b119290584 | ||
|
|
9b0cffcdea | ||
|
|
979466a3d7 | ||
|
|
08ba8dd487 | ||
|
|
a235a581e1 | ||
|
|
45b88de7f2 | ||
|
|
b360794c9c | ||
|
|
f69e017f6a | ||
|
|
19e5684875 | ||
|
|
24fc76b7fb | ||
|
|
0a4aad543b | ||
|
|
ab8584d138 | ||
|
|
0fc9ab925a | ||
|
|
9b6cd96012 | ||
|
|
dda5ee3334 | ||
|
|
14c67376c3 | ||
|
|
9c8b68a66d | ||
|
|
a3210157e2 | ||
|
|
1536d59e30 | ||
|
|
11e46a4560 | ||
|
|
bba1358637 | ||
|
|
e6af417c62 | ||
|
|
3509622323 | ||
|
|
80d7bd6084 | ||
|
|
8d3c3fd40b | ||
|
|
1caf7074a7 | ||
|
|
92bd155e3f | ||
|
|
8360b0f882 | ||
|
|
bc0d60138c | ||
|
|
32f377a665 | ||
|
|
2ab792b0fe | ||
|
|
2ae8de05dd | ||
|
|
cff96129b5 | ||
|
|
8abd1db7c1 | ||
|
|
a4d726184f | ||
|
|
85acf242f6 | ||
|
|
ba6d1d0c6b | ||
|
|
69d3453f97 | ||
|
|
a69cf6f5d4 | ||
|
|
0a46c2d16d | ||
|
|
3c1b8859bc | ||
|
|
9f645ae0a4 | ||
|
|
5eeeb9ed15 | ||
|
|
2061fc8a2f | ||
|
|
eb93350583 | ||
|
|
affdab7776 | ||
|
|
eb556adab5 | ||
|
|
09d886c676 | ||
|
|
11c5c6fb8b | ||
|
|
10804bbb56 | ||
|
|
e6f3b636ac | ||
|
|
e95676dd91 | ||
|
|
86b65e0912 | ||
|
|
e9dac06037 | ||
|
|
893cf60921 | ||
|
|
41c9f160a2 | ||
|
|
707abe6112 | ||
|
|
76975a134d | ||
|
|
672de432a2 | ||
|
|
4a6d88d9fb | ||
|
|
1ff836e549 | ||
|
|
08f038fe80 | ||
|
|
63279bcadf | ||
|
|
61628efd44 | ||
|
|
9b07f13cd3 | ||
|
|
1397a79b4c | ||
|
|
929115639d | ||
|
|
14dca40786 | ||
|
|
fa7596bceb | ||
|
|
5161f087fc | ||
|
|
71050ab076 | ||
|
|
614367ddcf | ||
|
|
3f7371445b | ||
|
|
a15a1ade17 | ||
|
|
798376b1d7 | ||
|
|
93271050bf | ||
|
|
8dfbabc691 | ||
|
|
af2522e5f0 | ||
|
|
452d42bd10 | ||
|
|
3e985377ce | ||
|
|
ab2e836d3f | ||
|
|
14158bea9c | ||
|
|
e14590636f | ||
|
|
ce3660d2e7 | ||
|
|
7853cb9db0 | ||
|
|
8cfeda1473 |
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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
7
.gitignore
vendored
@@ -38,6 +38,13 @@ metaData
|
||||
|
||||
# execution API authentication
|
||||
jwt.hex
|
||||
execution/
|
||||
|
||||
# local execution client data
|
||||
execution/
|
||||
|
||||
# local documentation
|
||||
CLAUDE.md
|
||||
|
||||
# manual testing
|
||||
tmp
|
||||
|
||||
85
CHANGELOG.md
85
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
95
beacon-chain/graffiti/graffiti-proposal-brief.md
Normal file
95
beacon-chain/graffiti/graffiti-proposal-brief.md
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: ðpb.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: ðpb.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(ðpb.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(ðpb.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(ðpb.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]
|
||||
|
||||
@@ -28,6 +28,7 @@ const (
|
||||
type Config struct {
|
||||
NoDiscovery bool
|
||||
EnableUPnP bool
|
||||
EnableAutoNAT bool
|
||||
StaticPeerID bool
|
||||
DisableLivenessCheck bool
|
||||
StaticPeers []string
|
||||
|
||||
@@ -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)
|
||||
|
||||
252
beacon-chain/p2p/gossip_peer_controller.go
Normal file
252
beacon-chain/p2p/gossip_peer_controller.go
Normal 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)
|
||||
}
|
||||
523
beacon-chain/p2p/gossip_peer_controller_test.go
Normal file
523
beacon-chain/p2p/gossip_peer_controller_test.go
Normal 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()
|
||||
}
|
||||
546
beacon-chain/p2p/gossip_peer_crawler.go
Normal file
546
beacon-chain/p2p/gossip_peer_crawler.go
Normal 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
|
||||
}
|
||||
787
beacon-chain/p2p/gossip_peer_crawler_test.go
Normal file
787
beacon-chain/p2p/gossip_peer_crawler_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
14
beacon-chain/p2p/gossipcrawler/BUILD.bazel
Normal file
14
beacon-chain/p2p/gossipcrawler/BUILD.bazel
Normal 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",
|
||||
],
|
||||
)
|
||||
35
beacon-chain/p2p/gossipcrawler/interface.go
Normal file
35
beacon-chain/p2p/gossipcrawler/interface.go
Normal 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
|
||||
}
|
||||
@@ -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...
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
398
beacon-chain/sync/docs/gossipsub_control_plane_design.md
Normal file
398
beacon-chain/sync/docs/gossipsub_control_plane_design.md
Normal 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 |
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
230
beacon-chain/sync/gossipsub_base.go
Normal file
230
beacon-chain/sync/gossipsub_base.go
Normal 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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
144
beacon-chain/sync/gossipsub_topic_family.go
Normal file
144
beacon-chain/sync/gossipsub_topic_family.go
Normal 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
|
||||
}
|
||||
311
beacon-chain/sync/gossipsub_topic_family_test.go
Normal file
311
beacon-chain/sync/gossipsub_topic_family_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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{})))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
199
beacon-chain/sync/subscription_controller.go
Normal file
199
beacon-chain/sync/subscription_controller.go
Normal 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
|
||||
}
|
||||
545
beacon-chain/sync/subscription_controller_test.go
Normal file
545
beacon-chain/sync/subscription_controller_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
277
beacon-chain/sync/topic_families_dynamic_subnets.go
Normal file
277
beacon-chain/sync/topic_families_dynamic_subnets.go
Normal 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
|
||||
}
|
||||
137
beacon-chain/sync/topic_families_dynamic_subnets_test.go
Normal file
137
beacon-chain/sync/topic_families_dynamic_subnets_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
38
beacon-chain/sync/topic_families_static_subnets.go
Normal file
38
beacon-chain/sync/topic_families_static_subnets.go
Normal 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)
|
||||
}
|
||||
247
beacon-chain/sync/topic_families_without_subnets.go
Normal file
247
beacon-chain/sync/topic_families_without_subnets.go
Normal 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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -499,6 +499,10 @@ func TestService_setSeenUnaggregatedAtt(t *testing.T) {
|
||||
Data: ðpb.AttestationData{Slot: 2, CommitteeIndex: 0},
|
||||
AggregationBits: bitfield.Bitlist{0b1001},
|
||||
}
|
||||
s3c0a0 := ðpb.Attestation{
|
||||
Data: ðpb.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 := ðpb.Attestation{AggregationBits: bitfield.Bitlist{0b1000}}
|
||||
@@ -576,6 +593,11 @@ func TestService_setSeenUnaggregatedAtt(t *testing.T) {
|
||||
CommitteeId: 0,
|
||||
AttesterIndex: 0,
|
||||
}
|
||||
s3c0a0 := ðpb.SingleAttestation{
|
||||
Data: ðpb.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 := ðpb.AttestationElectra{}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
### Changed
|
||||
|
||||
- Removed dead slot parameter from blobCacheEntry.filter
|
||||
@@ -1,3 +0,0 @@
|
||||
## Changed
|
||||
|
||||
- Avoid redundant WithHttpEndpoint when JWT is provided
|
||||
@@ -1,3 +0,0 @@
|
||||
### Fixed
|
||||
|
||||
- Fix proposals progress bar count [#16020](https://github.com/OffchainLabs/prysm/pull/16020)
|
||||
3
changelog/aarsh_libp2p_autonatv2_per_address.md
Normal file
3
changelog/aarsh_libp2p_autonatv2_per_address.md
Normal file
@@ -0,0 +1,3 @@
|
||||
### Added
|
||||
|
||||
- Add support for detecting and logging per address reachability via libp2p AutoNAT v2.
|
||||
3
changelog/aarshkshah1992-gossipsub-control-pane.md
Normal file
3
changelog/aarshkshah1992-gossipsub-control-pane.md
Normal file
@@ -0,0 +1,3 @@
|
||||
### Added
|
||||
|
||||
- A Gossipsub control plane with topic abstractions, a peer crawler and a peer controller.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Added
|
||||
|
||||
- Integrate state-diff into `HasState()`.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Ignored
|
||||
|
||||
- Refactor finding slot by block root using state summary and block to its own function.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Fixed
|
||||
|
||||
- Fix state diff repetitive anchor slot bug.
|
||||
@@ -1,4 +0,0 @@
|
||||
### Added
|
||||
|
||||
- Add initial configs for the state-diff feature.
|
||||
- Add kv functions for the state-diff feature.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Added
|
||||
|
||||
- Integrate state-diff into `State()`.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Added
|
||||
|
||||
- add fulu support to light client processing.
|
||||
@@ -1,2 +0,0 @@
|
||||
### Added
|
||||
- prometheus metric `gossip_attestation_verification_milliseconds` to track attestation gossip topic validation latency.
|
||||
@@ -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.
|
||||
@@ -1,2 +0,0 @@
|
||||
### Ignored
|
||||
- Add osaka fork timestamp derivation to interop genesis
|
||||
@@ -1,3 +0,0 @@
|
||||
### Fixed
|
||||
|
||||
- fixes E2E tests to be able to start from Electra genesis fork or future forks
|
||||
@@ -1,3 +0,0 @@
|
||||
### Ignored
|
||||
|
||||
- optimization to remove cell and blob proof computation on blob rest api.
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
### Changed
|
||||
|
||||
- Changed `--subscribe-all-data-subnets` flag to `--supernode` and aliased `--subscribe-all-data-subnets` for existing users.
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
### Changed
|
||||
|
||||
- Stop emitting payload attribute events during late block handling when we are not proposing the next slot
|
||||
@@ -1,3 +0,0 @@
|
||||
### Changed
|
||||
|
||||
- `blobsDataFromStoredDataColumns`: Ask the use to use the `--supernode` flag and shorten the error mesage.
|
||||
@@ -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.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Changed
|
||||
|
||||
- Move the "Not enough connected peers" (for a given subnet) from WARN to DEBUG
|
||||
@@ -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)
|
||||
2
changelog/manu-test-pr.md
Normal file
2
changelog/manu-test-pr.md
Normal file
@@ -0,0 +1,2 @@
|
||||
### Ignored
|
||||
- Added test requirement to `PULL_REQUEST_TEMPLATE.md`
|
||||
@@ -1,2 +0,0 @@
|
||||
### Fixed
|
||||
- Check the JWT secret length is exactly 256 bits (32 bytes) as per Engine API specification
|
||||
@@ -1,2 +0,0 @@
|
||||
### Changed
|
||||
- Improve readability in slashing import and remove duplicated code
|
||||
3
changelog/potuz_check_twice_attseen.md
Normal file
3
changelog/potuz_check_twice_attseen.md
Normal file
@@ -0,0 +1,3 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed possible race when validating two attestations at the same time.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Fixed
|
||||
|
||||
- Fix array out of bounds in static analyzer.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Changed
|
||||
|
||||
- Use dependent root instead of target when possible.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Fixed
|
||||
|
||||
- Use head state to validate attestations for old blocks if they are compatible.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,4 +0,0 @@
|
||||
### Ignored
|
||||
|
||||
- Updated golang.org/x/tools
|
||||
- Introduced modernize static analyzers to nogo
|
||||
@@ -1,3 +0,0 @@
|
||||
### Ignored
|
||||
|
||||
- Updated CHANGELOG.md with release notes from v7.0.0
|
||||
3
changelog/pvl-v7.0.1.md
Normal file
3
changelog/pvl-v7.0.1.md
Normal file
@@ -0,0 +1,3 @@
|
||||
### Ignored
|
||||
|
||||
- Updated CHANGELOG.md for v7.0.1 patch release
|
||||
3
changelog/pvl-v7.1.0.md
Normal file
3
changelog/pvl-v7.1.0.md
Normal file
@@ -0,0 +1,3 @@
|
||||
### Ignored
|
||||
|
||||
- Changelog for v7.1.0
|
||||
@@ -1,3 +0,0 @@
|
||||
### Changed
|
||||
|
||||
- Initialize the `ExecutionRequests` field in gossip block map.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Fixed
|
||||
|
||||
- Move `BlockGossipReceived` event to the end of gossip validation.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Removed
|
||||
|
||||
- Remove validator cross-client from end-to-end tests.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Ignored
|
||||
|
||||
- Replace fixed sleep delays with active polling in prometheus service test to improve test reliability.
|
||||
@@ -1,3 +0,0 @@
|
||||
### Added
|
||||
|
||||
- Metrics to track earliest available slot
|
||||
3
changelog/satushh-eth1copy.md
Normal file
3
changelog/satushh-eth1copy.md
Normal file
@@ -0,0 +1,3 @@
|
||||
### Removed
|
||||
|
||||
- Unnecessary copy is removed from Eth1DataHasEnoughSupport
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user