diff --git a/beacon/goclient/aggregator.go b/beacon/goclient/aggregator.go index 1285d9c375..9939a7309b 100644 --- a/beacon/goclient/aggregator.go +++ b/beacon/goclient/aggregator.go @@ -39,7 +39,7 @@ func (gc *GoClient) SubmitAggregateSelectionProof( if err != nil { return nil, DataVersionNil, fmt.Errorf("failed to get attestation data: %w", err) } - if gc.DataVersion(gc.beaconConfig.EstimatedEpochAtSlot(attData.Slot)) < spec.DataVersionElectra { + if gc.DataVersion(gc.getBeaconConfig().EstimatedEpochAtSlot(attData.Slot)) < spec.DataVersionElectra { attData.Index = committeeIndex } @@ -201,9 +201,9 @@ func isAggregator(committeeCount uint64, slotSig []byte) bool { // waitToSlotTwoThirds waits until two-third of the slot has transpired (SECONDS_PER_SLOT * 2 / 3 seconds after slot start time) func (gc *GoClient) waitToSlotTwoThirds(slot phase0.Slot) { - oneThird := gc.beaconConfig.SlotDuration / 3 /* one third of slot duration */ - - finalTime := gc.beaconConfig.GetSlotStartTime(slot).Add(2 * oneThird) + config := gc.getBeaconConfig() + oneThird := config.SlotDuration / 3 /* one third of slot duration */ + finalTime := config.GetSlotStartTime(slot).Add(2 * oneThird) wait := time.Until(finalTime) if wait <= 0 { return diff --git a/beacon/goclient/attest_test.go b/beacon/goclient/attest_test.go index ab7c2c208f..14d23a2ed1 100644 --- a/beacon/goclient/attest_test.go +++ b/beacon/goclient/attest_test.go @@ -18,8 +18,6 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/zap" - "github.com/ssvlabs/ssv/networkconfig" - "github.com/ssvlabs/ssv/operator/slotticker" "github.com/ssvlabs/ssv/utils/hashmap" ) @@ -204,17 +202,10 @@ func TestGoClient_GetAttestationData_Simple(t *testing.T) { t.Context(), zap.NewNop(), Options{ - BeaconConfig: networkconfig.Mainnet.BeaconConfig, BeaconNodeAddr: server.URL, CommonTimeout: 1 * time.Second, LongTimeout: 1 * time.Second, }, - func() slotticker.SlotTicker { - return slotticker.New(zap.NewNop(), slotticker.Config{ - SlotDuration: 12 * time.Second, - GenesisTime: time.Now(), - }) - }, ) require.NoError(t, err) @@ -500,18 +491,11 @@ func createClient( ctx, zap.NewNop(), Options{ - BeaconConfig: networkconfig.Mainnet.BeaconConfig, BeaconNodeAddr: beaconServerURL, CommonTimeout: defaultHardTimeout, LongTimeout: time.Second, WithWeightedAttestationData: withWeightedAttestationData, }, - func() slotticker.SlotTicker { - return slotticker.New(zap.NewNop(), slotticker.Config{ - SlotDuration: 12 * time.Second, - GenesisTime: time.Now(), - }) - }, ) return client, err } diff --git a/beacon/goclient/current_fork_test.go b/beacon/goclient/current_fork_test.go index ac68855a0e..70c8582c27 100644 --- a/beacon/goclient/current_fork_test.go +++ b/beacon/goclient/current_fork_test.go @@ -55,7 +55,6 @@ func TestCurrentFork(t *testing.T) { CommonTimeout: 100 * time.Millisecond, LongTimeout: 500 * time.Millisecond, }, - tests.MockSlotTickerProvider, ) require.NoError(t, err) @@ -86,7 +85,6 @@ func TestCurrentFork(t *testing.T) { CommonTimeout: 100 * time.Millisecond, LongTimeout: 500 * time.Millisecond, }, - tests.MockSlotTickerProvider, ) require.NoError(t, err) @@ -126,7 +124,6 @@ func TestCurrentFork(t *testing.T) { CommonTimeout: 100 * time.Millisecond, LongTimeout: 500 * time.Millisecond, }, - tests.MockSlotTickerProvider, ) require.NoError(t, err) diff --git a/beacon/goclient/events_test.go b/beacon/goclient/events_test.go index f316a49c9a..e807992d9e 100644 --- a/beacon/goclient/events_test.go +++ b/beacon/goclient/events_test.go @@ -13,7 +13,6 @@ import ( "go.uber.org/zap" "github.com/ssvlabs/ssv/beacon/goclient/tests" - "github.com/ssvlabs/ssv/networkconfig" ) func TestSubscribeToHeadEvents(t *testing.T) { @@ -84,9 +83,7 @@ func eventsTestClient(t *testing.T, serverURL string) *GoClient { zap.NewNop(), Options{ BeaconNodeAddr: serverURL, - BeaconConfig: networkconfig.Mainnet.BeaconConfig, - }, - tests.MockSlotTickerProvider) + }) require.NoError(t, err) return server diff --git a/beacon/goclient/genesis_test.go b/beacon/goclient/genesis_test.go index 095aa145ba..f9aec19ca4 100644 --- a/beacon/goclient/genesis_test.go +++ b/beacon/goclient/genesis_test.go @@ -14,7 +14,10 @@ import ( "github.com/ssvlabs/ssv/networkconfig" ) -const genesisPath = "/eth/v1/beacon/genesis" +const ( + genesisPath = "/eth/v1/beacon/genesis" + specPath = "/eth/v1/config/spec" +) func TestGenesis(t *testing.T) { ctx := context.Background() @@ -30,6 +33,29 @@ func TestGenesis(t *testing.T) { } }`), nil } + if r.URL.Path == specPath { + return json.RawMessage(`{ + "data": { + "CONFIG_NAME": "holesky", + "GENESIS_FORK_VERSION": "0x00000000", + "CAPELLA_FORK_VERSION": "0x04017000", + "MIN_GENESIS_TIME": "1695902100", + "SECONDS_PER_SLOT": "12", + "SLOTS_PER_EPOCH": "32", + "EPOCHS_PER_SYNC_COMMITTEE_PERIOD": "256", + "SYNC_COMMITTEE_SIZE": "512", + "SYNC_COMMITTEE_SUBNET_COUNT": "4", + "TARGET_AGGREGATORS_PER_COMMITTEE": "16", + "TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE": "16", + "INTERVALS_PER_SLOT": "3", + "ALTAIR_FORK_EPOCH": "74240", + "BELLATRIX_FORK_EPOCH": "144896", + "CAPELLA_FORK_EPOCH": "194048", + "DENEB_FORK_EPOCH": "269568", + "ELECTRA_FORK_EPOCH": "18446744073709551615" + } + }`), nil + } return resp, nil }) defer mockServer.Close() @@ -43,7 +69,6 @@ func TestGenesis(t *testing.T) { CommonTimeout: 100 * time.Millisecond, LongTimeout: 500 * time.Millisecond, }, - tests.MockSlotTickerProvider, ) require.NoError(t, err) @@ -73,13 +98,10 @@ func TestGenesis(t *testing.T) { CommonTimeout: 100 * time.Millisecond, LongTimeout: 500 * time.Millisecond, }, - tests.MockSlotTickerProvider, ) - require.NoError(t, err) - - _, err = client.Genesis(ctx) require.Error(t, err) - require.Contains(t, err.Error(), "genesis response data is nil") + require.Contains(t, err.Error(), "timed out awaiting config initialization") // node cannot initialize if it cannot get genesis + require.Nil(t, client) }) t.Run("error", func(t *testing.T) { @@ -100,12 +122,9 @@ func TestGenesis(t *testing.T) { CommonTimeout: 100 * time.Millisecond, LongTimeout: 500 * time.Millisecond, }, - tests.MockSlotTickerProvider, ) - require.NoError(t, err) - - _, err = client.Genesis(ctx) require.Error(t, err) - require.Contains(t, err.Error(), "failed to request genesis") + require.Contains(t, err.Error(), "timed out awaiting config initialization") // node cannot initialize if it cannot get genesis + require.Nil(t, client) }) } diff --git a/beacon/goclient/goclient.go b/beacon/goclient/goclient.go index 8266732a23..0bd2a40347 100644 --- a/beacon/goclient/goclient.go +++ b/beacon/goclient/goclient.go @@ -119,10 +119,14 @@ const ( // GoClient implementing Beacon struct type GoClient struct { - log *zap.Logger - beaconConfig networkconfig.BeaconConfig - clients []Client - multiClient MultiClient + log *zap.Logger + + beaconConfigMu sync.RWMutex + beaconConfig *networkconfig.BeaconConfig + beaconConfigInit chan struct{} + + clients []Client + multiClient MultiClient syncDistanceTolerance phase0.Slot nodeSyncingFn func(ctx context.Context, opts *api.NodeSyncingOpts) (*api.Response[*apiv1.SyncState], error) @@ -164,7 +168,6 @@ type GoClient struct { lastProcessedEventSlotLock sync.Mutex lastProcessedEventSlot phase0.Slot - genesisForkVersion phase0.Version ForkLock sync.RWMutex ForkEpochElectra phase0.Epoch ForkEpochDeneb phase0.Epoch @@ -182,11 +185,8 @@ func New( ctx context.Context, logger *zap.Logger, opt Options, - slotTickerProvider slotticker.Provider, ) (*GoClient, error) { - logger.Info("consensus client: connecting", - fields.Address(opt.BeaconNodeAddr), - fields.Network(opt.BeaconConfig.GetBeaconName())) + logger.Info("consensus client: connecting", fields.Address(opt.BeaconNodeAddr)) commonTimeout := opt.CommonTimeout if commonTimeout == 0 { @@ -198,18 +198,10 @@ func New( } client := &GoClient{ - log: logger.Named("consensus_client"), - beaconConfig: opt.BeaconConfig, - syncDistanceTolerance: phase0.Slot(opt.SyncDistanceTolerance), - registrations: map[phase0.BLSPubKey]*validatorRegistration{}, - attestationDataCache: ttlcache.New( - // we only fetch attestation data during the slot of the relevant duty (and never later), - // hence caching it for 2 slots is sufficient - ttlcache.WithTTL[phase0.Slot, *phase0.AttestationData](2 * opt.BeaconConfig.SlotDuration), - ), - blockRootToSlotCache: ttlcache.New(ttlcache.WithCapacity[phase0.Root, phase0.Slot]( - opt.BeaconConfig.SlotsPerEpoch * BlockRootToSlotCacheCapacityEpochs), - ), + log: logger.Named("consensus_client"), + beaconConfigInit: make(chan struct{}), + syncDistanceTolerance: phase0.Slot(opt.SyncDistanceTolerance), + registrations: map[phase0.BLSPubKey]*validatorRegistration{}, commonTimeout: commonTimeout, longTimeout: longTimeout, withWeightedAttestationData: opt.WithWeightedAttestationData, @@ -217,7 +209,6 @@ func New( weightedAttestationDataSoftTimeout: time.Duration(float64(commonTimeout) / 2.5), weightedAttestationDataHardTimeout: commonTimeout, supportedTopics: []EventTopic{EventTopicHead, EventTopicBlock}, - genesisForkVersion: opt.BeaconConfig.ForkVersion, // Initialize forks with FAR_FUTURE_EPOCH. ForkEpochAltair: math.MaxUint64, ForkEpochBellatrix: math.MaxUint64, @@ -249,6 +240,41 @@ func New( client.nodeSyncingFn = client.nodeSyncing + initCtx, initCtxCancel := context.WithTimeout(ctx, client.longTimeout) + defer initCtxCancel() + + select { + case <-initCtx.Done(): + logger.Warn("timeout occurred while waiting for beacon config initialization", + zap.Duration("timeout", client.longTimeout), + zap.Error(initCtx.Err()), + ) + return nil, fmt.Errorf("timed out awaiting config initialization: %w", initCtx.Err()) + case <-client.beaconConfigInit: + } + + config := client.getBeaconConfig() + if config == nil { + return nil, fmt.Errorf("no beacon config set") + } + + client.blockRootToSlotCache = ttlcache.New( + ttlcache.WithCapacity[phase0.Root, phase0.Slot](config.SlotsPerEpoch * BlockRootToSlotCacheCapacityEpochs), + ) + + client.attestationDataCache = ttlcache.New( + // we only fetch attestation data during the slot of the relevant duty (and never later), + // hence caching it for 2 slots is sufficient + ttlcache.WithTTL[phase0.Slot, *phase0.AttestationData](2 * config.SlotDuration), + ) + + slotTickerProvider := func() slotticker.SlotTicker { + return slotticker.New(logger, slotticker.Config{ + SlotDuration: config.SlotDuration, + GenesisTime: config.GenesisTime, + }) + } + go client.registrationSubmitter(ctx, slotTickerProvider) // Start automatic expired item deletion for attestationDataCache. go client.attestationDataCache.Start() @@ -261,6 +287,13 @@ func New( return client, nil } +// getBeaconConfig provides thread-safe access to the beacon configuration +func (gc *GoClient) getBeaconConfig() *networkconfig.BeaconConfig { + gc.beaconConfigMu.RLock() + defer gc.beaconConfigMu.RUnlock() + return gc.beaconConfig +} + func (gc *GoClient) initMultiClient(ctx context.Context) error { var services []eth2client.Service for _, client := range gc.clients { @@ -310,48 +343,46 @@ func (gc *GoClient) addSingleClient(ctx context.Context, addr string) error { func (gc *GoClient) singleClientHooks() *eth2clienthttp.Hooks { return ð2clienthttp.Hooks{ OnActive: func(ctx context.Context, s *eth2clienthttp.Service) { + logger := gc.log.With( + fields.Name(s.Name()), + fields.Address(s.Address()), + ) // If err is nil, nodeVersionResp is never nil. nodeVersionResp, err := s.NodeVersion(ctx, &api.NodeVersionOpts{}) if err != nil { - gc.log.Error(clResponseErrMsg, - zap.String("address", s.Address()), + logger.Error(clResponseErrMsg, zap.String("api", "NodeVersion"), zap.Error(err), ) return } - gc.log.Info("consensus client connected", - fields.Name(s.Name()), - fields.Address(s.Address()), + logger.Info("consensus client connected", zap.String("client", string(ParseNodeClient(nodeVersionResp.Data))), zap.String("version", nodeVersionResp.Data), ) - genesis, err := genesisForClient(ctx, gc.log, s) + beaconConfig, err := gc.fetchBeaconConfig(ctx, s) if err != nil { - gc.log.Error(clResponseErrMsg, - zap.String("address", s.Address()), - zap.String("api", "Genesis"), + logger.Error(clResponseErrMsg, + zap.String("api", "fetchBeaconConfig"), zap.Error(err), ) return } - if expected, err := gc.assertSameGenesisVersion(genesis.GenesisForkVersion); err != nil { - gc.log.Fatal("client returned unexpected genesis fork version, make sure all clients use the same Ethereum network", - zap.String("address", s.Address()), - zap.Any("client_genesis", genesis.GenesisForkVersion), - zap.Any("expected_genesis", expected), - zap.Error(err), + currentConfig, err := gc.applyBeaconConfig(s.Address(), beaconConfig) + if err != nil { + logger.Fatal("client returned unexpected beacon config, make sure all clients use the same Ethereum network", + zap.Stringer("client_config", beaconConfig), + zap.Stringer("expected_config", currentConfig), ) return // Tests may override Fatal's behavior } - spec, err := specImpl(ctx, gc.log, s) + spec, err := specForClient(ctx, logger, s) if err != nil { - gc.log.Error(clResponseErrMsg, - zap.String("address", s.Address()), + logger.Error(clResponseErrMsg, zap.String("api", "Spec"), zap.Error(err), ) @@ -359,16 +390,15 @@ func (gc *GoClient) singleClientHooks() *eth2clienthttp.Hooks { } if err := gc.checkForkValues(spec); err != nil { - gc.log.Error("failed to check fork values", - zap.String("address", s.Address()), + logger.Error("failed to check fork values", zap.Error(err), ) return } gc.ForkLock.RLock() - gc.log.Info("retrieved fork epochs", - zap.String("node_addr", s.Address()), - zap.Uint64("current_data_version", uint64(gc.DataVersion(gc.beaconConfig.EstimatedCurrentEpoch()))), + config := gc.getBeaconConfig() + logger.Info("retrieved fork epochs", + zap.Uint64("current_data_version", uint64(gc.DataVersion(config.EstimatedCurrentEpoch()))), zap.Uint64("altair", uint64(gc.ForkEpochAltair)), zap.Uint64("bellatrix", uint64(gc.ForkEpochBellatrix)), zap.Uint64("capella", uint64(gc.ForkEpochCapella)), @@ -398,18 +428,26 @@ func (gc *GoClient) singleClientHooks() *eth2clienthttp.Hooks { } } -// assertSameGenesis checks if genesis is same. -// Clients may have different values returned by Spec call, -// so we decided that it's best to assert that GenesisForkVersion is the same. -// To add more assertions, we check the whole apiv1.Genesis (GenesisTime and GenesisValidatorsRoot) -// as they should be same too. -func (gc *GoClient) assertSameGenesisVersion(genesisVersion phase0.Version) (phase0.Version, error) { - if gc.genesisForkVersion != genesisVersion { - fmt.Printf("genesis fork version mismatch, expected %v, got %v", gc.genesisForkVersion, genesisVersion) - return gc.genesisForkVersion, fmt.Errorf("genesis fork version mismatch, expected %v, got %v", gc.genesisForkVersion, genesisVersion) +func (gc *GoClient) applyBeaconConfig(nodeAddress string, beaconConfig networkconfig.BeaconConfig) (networkconfig.BeaconConfig, error) { + gc.beaconConfigMu.Lock() + defer gc.beaconConfigMu.Unlock() + + if gc.beaconConfig == nil { + gc.beaconConfig = &beaconConfig + close(gc.beaconConfigInit) + + gc.log.Info("beacon config has been initialized", + zap.Stringer("beacon_config", beaconConfig), + fields.Address(nodeAddress), + ) + return beaconConfig, nil + } + + if *gc.beaconConfig != beaconConfig { + return *gc.beaconConfig, fmt.Errorf("beacon config misalign, current %v, got %v", gc.beaconConfig, beaconConfig) } - return gc.genesisForkVersion, nil + return *gc.beaconConfig, nil } func (gc *GoClient) nodeSyncing(ctx context.Context, opts *api.NodeSyncingOpts) (*api.Response[*apiv1.SyncState], error) { diff --git a/beacon/goclient/goclient_test.go b/beacon/goclient/goclient_test.go index dc9241d599..ab17fb7dfa 100644 --- a/beacon/goclient/goclient_test.go +++ b/beacon/goclient/goclient_test.go @@ -15,7 +15,6 @@ import ( "go.uber.org/zap" "github.com/ssvlabs/ssv/beacon/goclient/tests" - "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/protocol/v2/blockchain/beacon" ) @@ -135,6 +134,10 @@ func TestTimeouts(t *testing.T) { fastServer := tests.MockServer(func(r *http.Request, resp json.RawMessage) (json.RawMessage, error) { time.Sleep(commonTimeout / 2) switch r.URL.Path { + case "/eth/v1/config/spec": + case "/eth/v1/beacon/genesis": + case "/eth/v1/node/syncing": + case "/eth/v1/node/version": case "/eth/v2/debug/beacon/states/head": time.Sleep(longTimeout / 2) } @@ -153,72 +156,14 @@ func TestTimeouts(t *testing.T) { } } -func TestAssertSameGenesisVersionWhenSame(t *testing.T) { - networkConfigs := []networkconfig.NetworkConfig{ - networkconfig.Mainnet, - networkconfig.Holesky, - networkconfig.LocalTestnet, - networkconfig.TestNetwork, - } - - for _, netCfg := range networkConfigs { - callback := func(r *http.Request, resp json.RawMessage) (json.RawMessage, error) { - if r.URL.Path == "/eth/v1/beacon/genesis" { - resp2 := json.RawMessage(fmt.Sprintf(`{"data": { - "genesis_time": "1606824023", - "genesis_validators_root": "0x4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95", - "genesis_fork_version": "%s" - }}`, netCfg.ForkVersion)) - return resp2, nil - } - return resp, nil - } - - server := tests.MockServer(callback) - defer server.Close() - t.Run(fmt.Sprintf("When genesis versions are the same (%s)", netCfg.BeaconName), func(t *testing.T) { - c, err := mockClientWithNetwork(t.Context(), server.URL, 100*time.Millisecond, 500*time.Millisecond, netCfg.BeaconConfig) - require.NoError(t, err, "failed to create client") - client := c.(*GoClient) - - output, err := client.assertSameGenesisVersion(netCfg.ForkVersion) - require.Equal(t, netCfg.ForkVersion, output) - require.NoError(t, err, "failed to assert same genesis version: %s", err) - }) - } -} - -func TestAssertSameGenesisVersionWhenDifferent(t *testing.T) { - networkConfig := networkconfig.Mainnet - - t.Run("When genesis versions are different", func(t *testing.T) { - server := tests.MockServer(nil) - defer server.Close() - c, err := mockClientWithNetwork(t.Context(), server.URL, 100*time.Millisecond, 500*time.Millisecond, networkConfig.BeaconConfig) - require.NoError(t, err, "failed to create client") - client := c.(*GoClient) - forkVersion := phase0.Version{0x01, 0x02, 0x03, 0x04} - - output, err := client.assertSameGenesisVersion(forkVersion) - require.Equal(t, networkConfig.ForkVersion, output, "expected genesis version to be %s, got %s", networkConfig.ForkVersion, output) - require.Error(t, err, "expected error when genesis versions are different") - }) -} - func mockClient(ctx context.Context, serverURL string, commonTimeout, longTimeout time.Duration) (beacon.BeaconNode, error) { - return mockClientWithNetwork(ctx, serverURL, commonTimeout, longTimeout, networkconfig.Mainnet.BeaconConfig) -} - -func mockClientWithNetwork(ctx context.Context, serverURL string, commonTimeout, longTimeout time.Duration, beaconConfig networkconfig.BeaconConfig) (beacon.BeaconNode, error) { return New( ctx, zap.NewNop(), Options{ - BeaconConfig: beaconConfig, BeaconNodeAddr: serverURL, CommonTimeout: commonTimeout, LongTimeout: longTimeout, }, - tests.MockSlotTickerProvider, ) } diff --git a/beacon/goclient/signing.go b/beacon/goclient/signing.go index 0897fedef9..570d027c74 100644 --- a/beacon/goclient/signing.go +++ b/beacon/goclient/signing.go @@ -31,6 +31,7 @@ func (gc *GoClient) voluntaryExitDomain(ctx context.Context) (phase0.Domain, err } func (gc *GoClient) computeVoluntaryExitDomain(ctx context.Context) (phase0.Domain, error) { + // TODO: pull from beacon node specResponse, err := gc.Spec(ctx) if err != nil { return phase0.Domain{}, fmt.Errorf("fetch spec: %w", err) @@ -80,7 +81,7 @@ func (gc *GoClient) DomainData( // to (Mainnet, Hoodi, etc.) var appDomain phase0.Domain forkData := phase0.ForkData{ - CurrentVersion: gc.beaconConfig.ForkVersion, + CurrentVersion: gc.getBeaconConfig().ForkVersion, GenesisValidatorsRoot: phase0.Root{}, } root, err := forkData.HashTreeRoot() diff --git a/beacon/goclient/signing_test.go b/beacon/goclient/signing_test.go index a737a144a8..298ad6e141 100644 --- a/beacon/goclient/signing_test.go +++ b/beacon/goclient/signing_test.go @@ -31,7 +31,6 @@ func Test_computeVoluntaryExitDomain(t *testing.T) { CommonTimeout: 100 * time.Millisecond, LongTimeout: 500 * time.Millisecond, }, - tests.MockSlotTickerProvider, ) require.NoError(t, err) diff --git a/beacon/goclient/spec.go b/beacon/goclient/spec.go index 8b3660d8f7..5874ed350a 100644 --- a/beacon/goclient/spec.go +++ b/beacon/goclient/spec.go @@ -8,15 +8,87 @@ import ( client "github.com/attestantio/go-eth2-client" "github.com/attestantio/go-eth2-client/api" + eth2clienthttp "github.com/attestantio/go-eth2-client/http" "go.uber.org/zap" + + "github.com/ssvlabs/ssv/networkconfig" +) + +const ( + DefaultSlotDuration = 12 * time.Second + DefaultSlotsPerEpoch = uint64(32) ) +// BeaconConfig returns the network Beacon configuration +func (gc *GoClient) BeaconConfig() networkconfig.BeaconConfig { + // BeaconConfig must be called if GoClient is initialized (gc.beaconConfig is set) + // It fails otherwise. + config := gc.getBeaconConfig() + return *config +} + +// fetchBeaconConfig must be called once on GoClient's initialization +func (gc *GoClient) fetchBeaconConfig(ctx context.Context, client *eth2clienthttp.Service) (networkconfig.BeaconConfig, error) { + specResponse, err := specForClient(ctx, gc.log, client) + if err != nil { + gc.log.Error(clResponseErrMsg, zap.String("api", "Spec"), zap.Error(err)) + return networkconfig.BeaconConfig{}, fmt.Errorf("failed to obtain spec response: %w", err) + } + + // types of most values are already cast: https://github.com/attestantio/go-eth2-client/blob/v0.21.7/http/spec.go#L78 + + networkNameRaw, ok := specResponse["CONFIG_NAME"] + if !ok { + return networkconfig.BeaconConfig{}, fmt.Errorf("config name not known by chain") + } + + networkName, ok := networkNameRaw.(string) + if !ok { + return networkconfig.BeaconConfig{}, fmt.Errorf("failed to decode config name") + } + + slotDuration := DefaultSlotDuration + if slotDurationRaw, ok := specResponse["SECONDS_PER_SLOT"]; ok { + if slotDurationDecoded, ok := slotDurationRaw.(time.Duration); ok { + slotDuration = slotDurationDecoded + } else { + gc.log.Warn("seconds per slot not known by chain, using default value", + zap.Any("value", slotDuration)) + } + } + + slotsPerEpoch := DefaultSlotsPerEpoch + if slotsPerEpochRaw, ok := specResponse["SLOTS_PER_EPOCH"]; ok { + if slotsPerEpochDecoded, ok := slotsPerEpochRaw.(uint64); ok { + slotsPerEpoch = slotsPerEpochDecoded + } else { + gc.log.Warn("slots per epoch not known by chain, using default value", + zap.Uint64("value", slotsPerEpoch)) + } + } + + genesisResponse, err := genesisForClient(ctx, gc.log, client) + if err != nil { + gc.log.Error(clResponseErrMsg, zap.String("api", "Genesis"), zap.Error(err)) + return networkconfig.BeaconConfig{}, fmt.Errorf("failed to obtain genesis response: %w", err) + } + + beaconConfig := networkconfig.BeaconConfig{ + BeaconName: networkName, + SlotDuration: slotDuration, + SlotsPerEpoch: slotsPerEpoch, + ForkVersion: genesisResponse.GenesisForkVersion, + GenesisTime: genesisResponse.GenesisTime, + } + + return beaconConfig, nil +} + func (gc *GoClient) Spec(ctx context.Context) (map[string]any, error) { - return specImpl(ctx, gc.log, gc.multiClient) + return specForClient(ctx, gc.log, gc.multiClient) } -// It's used in both Spec and singleClientHooks, so we need some common implementation to avoid code repetition. -func specImpl(ctx context.Context, log *zap.Logger, provider client.Service) (map[string]any, error) { +func specForClient(ctx context.Context, log *zap.Logger, provider client.Service) (map[string]any, error) { start := time.Now() specResponse, err := provider.(client.SpecProvider).Spec(ctx, &api.SpecOpts{}) recordRequestDuration(ctx, "Spec", provider.Address(), http.MethodGet, time.Since(start), err) diff --git a/beacon/goclient/spec_test.go b/beacon/goclient/spec_test.go index 9467f9abf1..da28b512eb 100644 --- a/beacon/goclient/spec_test.go +++ b/beacon/goclient/spec_test.go @@ -28,7 +28,6 @@ func TestSpec(t *testing.T) { CommonTimeout: 100 * time.Millisecond, LongTimeout: 500 * time.Millisecond, }, - tests.MockSlotTickerProvider, ) require.NoError(t, err) diff --git a/beacon/goclient/sync_committee_contribution.go b/beacon/goclient/sync_committee_contribution.go index 609709ec4c..2e43ae4634 100644 --- a/beacon/goclient/sync_committee_contribution.go +++ b/beacon/goclient/sync_committee_contribution.go @@ -153,8 +153,9 @@ func (gc *GoClient) SubmitSignedContributionAndProof( // waitForOneThirdSlotDuration waits until one-third of the slot has transpired (SECONDS_PER_SLOT / 3 seconds after slot start time) func (gc *GoClient) waitForOneThirdSlotDuration(slot phase0.Slot) { - delay := gc.beaconConfig.SlotDuration / 3 /* a third of the slot duration */ - finalTime := gc.beaconConfig.GetSlotStartTime(slot).Add(delay) + config := gc.getBeaconConfig() + delay := config.SlotDuration / 3 /* a third of the slot duration */ + finalTime := config.GetSlotStartTime(slot).Add(delay) wait := time.Until(finalTime) if wait <= 0 { return diff --git a/beacon/goclient/validator.go b/beacon/goclient/validator.go index 9d4dab04f8..e16dce98b8 100644 --- a/beacon/goclient/validator.go +++ b/beacon/goclient/validator.go @@ -103,9 +103,10 @@ func (gc *GoClient) registrationSubmitter(ctx context.Context, slotTickerProvide } // Distribute the registrations evenly across the epoch based on the pubkeys. - slotInEpoch := uint64(currentSlot) % gc.beaconConfig.SlotsPerEpoch + config := gc.getBeaconConfig() + slotInEpoch := uint64(currentSlot) % config.SlotsPerEpoch validatorDescriptor := xxhash.Sum64(validatorPk[:]) - shouldSubmit := validatorDescriptor%gc.beaconConfig.SlotsPerEpoch == slotInEpoch + shouldSubmit := validatorDescriptor%config.SlotsPerEpoch == slotInEpoch if r.new || shouldSubmit { r.new = false diff --git a/cli/GENERATE_CONFIG.md b/cli/GENERATE_CONFIG.md new file mode 100644 index 0000000000..470b63d28a --- /dev/null +++ b/cli/GENERATE_CONFIG.md @@ -0,0 +1,160 @@ +# SSV Config Generator + +**SSV Config Generator** is a command-line tool designed to generate configuration file for SSV Node. It streamlines the setup process by allowing users to specify various parameters through flags, which are then compiled into a YAML configuration file. This tool ensures that the SSV network setup is consistent, customizable, and easy to manage. + +## Table of Contents + +- [Usage](#usage) + - [Flags](#flags) +- [Examples](#examples) +- [Configuration](#configuration) + +## Usage + +The `generate-config` command allows you to generate a YAML configuration file by specifying various parameters through command-line flags. + +### Syntax + +```bash + ssvnode generate-config [flags] +``` + +### Flags + +| Flag | Type | Default | Description | +|--------------------------------------|--------|-----------------------------------|----------------------------------------------------------------------------| +| `--output-path` | string | `./config/config.local.yaml` | Output path for the generated configuration file. | +| `--log-level` | string | `info` | Sets the logging level (e.g., `debug`, `info`, `warn`, `error`). | +| `--db-path` | string | `./data/db` | Path to the database directory. | +| `--discovery` | string | `mdns` | Discovery method. | +| `--consensus-client` | string | _Mandatory_ | Address of the consensus client (e.g., `http://localhost:9000`). | +| `--execution-client` | string | _Mandatory_ | Address of the execution client (e.g., `http://localhost:8545`). | +| `--operator-private-key` | string | | Secret key for the operator. | +| `--metrics-api-port` | int | `0` | Port number for the Metrics API (set to `0` to disable). | +| `--ssv-domain` | string | Derived from local testnet config | Hex-encoded domain type (prefixed with `0x`). | +| `--ssv-registry-sync-offset` | uint64 | Derived from local testnet config | Registry sync offset for the network. | +| `--ssv-registry-contract-addr` | string | Derived from local testnet config | Ethereum address of the network registry contract (e.g., `0xYourAddress`). | +| `--ssv-bootnodes` | string | Derived from local testnet config | Comma-separated list of network bootnodes. | +| `--ssv-discovery-protocol-id` | string | Derived from local testnet config | Hex-encoded discovery protocol ID (prefixed with `0x`). | +| `--ssv-alan-fork-epoch` | uint64 | Derived from local testnet config | Epoch at which the Alan fork occurs in the network. | +| `--ssv-max-validators-per-committee` | int | Derived from local testnet config | Max validators per committee. | + + +**Note:** The `--consensus-client` and `--execution-client` flags are mandatory and must be provided when running the CLI. + +## Examples + +### Basic Configuration + +Generate a configuration file with default settings, specifying only the mandatory flags: + +```bash +ssvnode generate-config \ + --consensus-client "http://localhost:9000" \ + --execution-client "http://localhost:8545" +``` + +This command generates a `config.local.yaml` file in the `./config` directory with default settings for all other parameters. + +### Custom Output Path and Log Level + +Generate a configuration file with a custom output path and set the log level to `debug`: + +```bash +ssvnode generate-config \ + --consensus-client "http://consensus.example.com:9000" \ + --execution-client "http://execution.example.com:8545" \ + --output-path "/etc/ssv/config.yaml" \ + --log-level "debug" +``` + +### Specify Operator Private Key and Enable Metrics API + +Generate a configuration with an operator's private key and enable the Metrics API on port `8080`: + +```bash +ssvnode generate-config \ + --consensus-client "http://consensus.example.com:9000" \ + --execution-client "http://execution.example.com:8545" \ + --operator-private-key "your-operator-private-key" \ + --metrics-api-port 8080 +``` + +### Advanced Network Configuration + +Customize network settings such as bootnodes, discovery protocol ID, fork epoch, etc: + +```bash +ssvnode generate-config \ + --consensus-client "http://consensus.example.com:9000" \ + --execution-client "http://execution.example.com:8545" \ + --ssv-domain "0x12345678" \ + --ssv-registry-sync-offset 50 \ + --ssv-registry-contract-addr "0xYourRegistryContractAddress" \ + --ssv-bootnodes "enode://bootnode1@127.0.0.1:30303,enode://bootnode2@127.0.0.1:30304" \ + --ssv-discovery-protocol-id "0x1234567890ab" \ + --ssv-max-validators-per-committee 560 +``` + +## Configuration + +The generated YAML configuration file (`config.local.yaml` by default) follows the structure defined by the `Config` struct. Below is an overview of each section and its corresponding fields: + +### YAML Structure + +```yaml +global: + LogLevel: info +db: + Path: ./data/db +eth2: + BeaconNodeAddr: http://localhost:9000 +eth1: + ETH1Addr: http://localhost:8545 +p2p: + Discovery: mdns +ssv: + Network: LocalTestnetSSV + CustomNetwork: + DomainType: "0x12345678" + RegistrySyncOffset: 0 + RegistryContractAddr: "0xYourRegistryContractAddress" + Bootnodes: + - "enode://bootnode1@127.0.0.1:30303" + - "enode://bootnode2@127.0.0.1:30304" + DiscoveryProtocolID: "0x1234567890ab" +OperatorPrivateKey: your-operator-private-key +MetricsAPIPort: 8080 +``` + +### Sections + +- **global** + - `LogLevel`: Specifies the logging level (e.g., `debug`, `info`, `warn`, `error`). + +- **db** + - `Path`: Path to the database directory. + +- **eth2** + - `BeaconNodeAddr`: Address of the consensus client (Beacon Node). + +- **eth1** + - `ETH1Addr`: Address of the execution client (ETH1 Node). + +- **p2p** + - `Discovery`: Peer-to-peer discovery method (e.g., `mdns`). + +- **ssv** + - `Network`: Name of the network. + - `CustomNetwork`: Contains custom network parameters. + - `DomainType`: Hex-encoded domain type (prefixed with `0x`). + - `RegistrySyncOffset`: Registry sync offset for the network. + - `RegistryContractAddr`: Ethereum address of the network registry contract. + - `Bootnodes`: List of network bootnodes. + - `DiscoveryProtocolID`: Hex-encoded discovery protocol ID (prefixed with `0x`). + +- **OperatorPrivateKey** + - `OperatorPrivateKey`: Secret key for the operator. + +- **MetricsAPIPort** + - `MetricsAPIPort`: Port number for the Metrics API. \ No newline at end of file diff --git a/cli/bootnode/boot_node.go b/cli/bootnode/boot_node.go index 2b1a564ef3..475ad0b4b7 100644 --- a/cli/bootnode/boot_node.go +++ b/cli/bootnode/boot_node.go @@ -54,7 +54,7 @@ var StartBootNodeCmd = &cobra.Command{ logger.Info(fmt.Sprintf("starting %v", commons.GetBuildData())) - networkConfig, err := networkconfig.GetNetworkConfigByName(cfg.Options.Network) + networkConfig, err := networkconfig.GetSSVConfigByName(cfg.Options.Network) if err != nil { logger.Fatal("failed to get network config", zap.Error(err)) } diff --git a/cli/generate_config.go b/cli/generate_config.go new file mode 100644 index 0000000000..41de6e6d58 --- /dev/null +++ b/cli/generate_config.go @@ -0,0 +1,149 @@ +package cli + +import ( + "encoding/hex" + "fmt" + "log" + "math/big" + "os" + "strings" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" + spectypes "github.com/ssvlabs/ssv-spec/types" + "gopkg.in/yaml.v3" + + "github.com/ssvlabs/ssv/networkconfig" +) + +const ( + defaultOutputPath = "./config/config.local.yaml" + defaultLogLevel = "info" + defaultDBPath = "./data/db" + defaultDiscovery = "mdns" + sliceSeparator = "," + configFilePermissions = 0644 +) + +var ( + defaultNetwork = networkconfig.LocalTestnet +) + +var ( + outputPath string + logLevel string + dbPath string + discovery string + consensusClient string + executionClient string + operatorPrivateKey string + metricsAPIPort int + ssvDomain string + ssvRegistrySyncOffset uint64 + ssvRegistryContractAddr string + ssvBootnodes string + ssvDiscoveryProtocolID string +) + +type SSVConfig struct { + Global struct { + LogLevel string `yaml:"LogLevel,omitempty"` + } `yaml:"global,omitempty"` + DB struct { + Path string `yaml:"Path,omitempty"` + } `yaml:"db,omitempty"` + ConsensusClient struct { + Address string `yaml:"BeaconNodeAddr,omitempty"` + } `yaml:"eth2,omitempty"` + ExecutionClient struct { + Address string `yaml:"ETH1Addr,omitempty"` + } `yaml:"eth1,omitempty"` + P2P struct { + Discovery string `yaml:"Discovery,omitempty"` + } `yaml:"p2p,omitempty"` + SSV struct { + NetworkName string `yaml:"Network,omitempty" env:"NETWORK" env-description:"Network is the network of this node,omitempty"` + CustomNetwork *networkconfig.SSVConfig `yaml:"CustomNetwork,omitempty" env:"CUSTOM_NETWORK" env-description:"Custom network parameters,omitempty"` + } `yaml:"ssv,omitempty"` + OperatorPrivateKey string `yaml:"OperatorPrivateKey,omitempty"` + MetricsAPIPort int `yaml:"MetricsAPIPort,omitempty"` +} + +// generateConfigCmd is the command to generate ssv operator config. +var generateConfigCmd = &cobra.Command{ + Use: "generate-config", + Short: "generates ssv operator config", + Run: func(cmd *cobra.Command, args []string) { + parsedDomain, err := hex.DecodeString(strings.TrimPrefix(ssvDomain, "0x")) + if err != nil { + log.Fatalf("Failed to decode network domain: %v", err) + } + + parsedDiscoveryProtocolID, err := hex.DecodeString(strings.TrimPrefix(ssvDiscoveryProtocolID, "0x")) + if err != nil { + log.Fatalf("Failed to decode discovery protocol ID: %v", err) + } + + var parsedDiscoveryProtocolIDArr [6]byte + if len(parsedDiscoveryProtocolID) != 0 { + parsedDiscoveryProtocolIDArr = [6]byte(parsedDiscoveryProtocolID) + } + + var bootnodes []string + if ssvBootnodes != "" { + bootnodes = strings.Split(ssvBootnodes, sliceSeparator) + } + + var config SSVConfig + config.Global.LogLevel = logLevel + config.DB.Path = dbPath + config.ConsensusClient.Address = consensusClient + config.ExecutionClient.Address = executionClient + config.P2P.Discovery = discovery + config.OperatorPrivateKey = operatorPrivateKey + config.MetricsAPIPort = metricsAPIPort + config.SSV.CustomNetwork = &networkconfig.SSVConfig{ + DomainType: spectypes.DomainType(parsedDomain), + RegistrySyncOffset: new(big.Int).SetUint64(ssvRegistrySyncOffset), + RegistryContractAddr: ethcommon.HexToAddress(ssvRegistryContractAddr), + Bootnodes: bootnodes, + DiscoveryProtocolID: parsedDiscoveryProtocolIDArr, + } + + data, err := yaml.Marshal(&config) + if err != nil { + log.Fatalf("Failed to marshal YAML: %v", err) + } + + err = os.WriteFile(outputPath, data, configFilePermissions) + if err != nil { + log.Fatalf("Failed to write file: %v", err) + } + + log.Printf("Saved config into '%s':", outputPath) + fmt.Println(string(data)) + }, +} + +func init() { + generateConfigCmd.Flags().StringVarP(&outputPath, "output-path", "o", defaultOutputPath, "Output path for generated config") + generateConfigCmd.Flags().StringVar(&logLevel, "log-level", defaultLogLevel, "Log level") + generateConfigCmd.Flags().StringVar(&dbPath, "db-path", defaultDBPath, "DB path") + generateConfigCmd.Flags().StringVar(&discovery, "discovery", defaultDiscovery, "Discovery") + generateConfigCmd.Flags().StringVar(&consensusClient, "consensus-client", "", "Consensus client (required)") + _ = generateConfigCmd.MarkFlagRequired("consensus-client") + generateConfigCmd.Flags().StringVar(&executionClient, "execution-client", "", "Execution client (required)") + _ = generateConfigCmd.MarkFlagRequired("execution-client") + generateConfigCmd.Flags().StringVar(&operatorPrivateKey, "operator-private-key", "", "Secret key") + generateConfigCmd.Flags().IntVar(&metricsAPIPort, "metrics-api-port", 0, "Metrics API port") + + ssvDomainDefault := "0x" + hex.EncodeToString(defaultNetwork.DomainType[:]) + generateConfigCmd.Flags().StringVar(&ssvDomain, "ssv-domain", ssvDomainDefault, "SSV domain type") + generateConfigCmd.Flags().Uint64Var(&ssvRegistrySyncOffset, "ssv-registry-sync-offset", defaultNetwork.RegistrySyncOffset.Uint64(), "SSV registry sync offset") + generateConfigCmd.Flags().StringVar(&ssvRegistryContractAddr, "ssv-registry-contract-addr", defaultNetwork.RegistryContractAddr.String(), "SSV registry contract addr") + generateConfigCmd.Flags().StringVar(&ssvBootnodes, "ssv-bootnodes", strings.Join(defaultNetwork.Bootnodes, sliceSeparator), "SSV bootnodes (comma-separated)") + ssvDiscoveryProtocolIDDefault := "0x" + hex.EncodeToString(defaultNetwork.DiscoveryProtocolID[:]) + generateConfigCmd.Flags().StringVar(&ssvDiscoveryProtocolID, "ssv-discovery-protocol-id", ssvDiscoveryProtocolIDDefault, "SSV discovery protocol ID") + + RootCmd.AddCommand(generateConfigCmd) +} diff --git a/cli/operator/node.go b/cli/operator/node.go index 8d7b23e62b..a08f4626f2 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -16,7 +16,6 @@ import ( "time" "github.com/attestantio/go-eth2-client/spec/phase0" - ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ilyakaznacheev/cleanenv" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -142,10 +141,23 @@ var StartNodeCmd = &cobra.Command{ } }() - networkConfig, err := setupSSVNetwork(logger) + ssvNetworkConfig, err := setupSSVNetwork(logger) if err != nil { logger.Fatal("could not setup network", zap.Error(err)) } + + consensusClient, err := goclient.New(cmd.Context(), logger, cfg.ConsensusClient) + if err != nil { + logger.Fatal("failed to create beacon go-client", zap.Error(err), + fields.Address(cfg.ConsensusClient.BeaconNodeAddr)) + } + + networkConfig := networkconfig.NetworkConfig{ + Name: cfg.SSVOptions.NetworkName, + SSVConfig: ssvNetworkConfig, + BeaconConfig: consensusClient.BeaconConfig(), + } + cfg.DBOptions.Ctx = cmd.Context() db, err := setupDB(logger, networkConfig) if err != nil { @@ -261,17 +273,7 @@ var StartNodeCmd = &cobra.Command{ } cfg.P2pNetworkConfig.Ctx = cmd.Context() - - slotTickerProvider := func() slotticker.SlotTicker { - return slotticker.New(logger, slotticker.Config{ - SlotDuration: networkConfig.SlotDuration, - GenesisTime: networkConfig.GenesisTime, - }) - } - - cfg.ConsensusClient.BeaconConfig = networkConfig.BeaconConfig operatorDataStore := setupOperatorDataStore(logger, nodeStorage, operatorPubKeyBase64) - consensusClient := setupConsensusClient(cmd.Context(), logger, slotTickerProvider) executionAddrList := strings.Split(cfg.ExecutionClient.Addr, ";") // TODO: Decide what symbol to use as a separator. Bootnodes are currently separated by ";". Deployment bot currently uses ",". if len(executionAddrList) == 0 { @@ -284,7 +286,7 @@ var StartNodeCmd = &cobra.Command{ ec, err := executionclient.New( cmd.Context(), executionAddrList[0], - ethcommon.HexToAddress(networkConfig.RegistryContractAddr), + ssvNetworkConfig.RegistryContractAddr, executionclient.WithLogger(logger), executionclient.WithFollowDistance(executionclient.DefaultFollowDistance), executionclient.WithConnectionTimeout(cfg.ExecutionClient.ConnectionTimeout), @@ -302,7 +304,7 @@ var StartNodeCmd = &cobra.Command{ ec, err := executionclient.NewMulti( cmd.Context(), executionAddrList, - ethcommon.HexToAddress(networkConfig.RegistryContractAddr), + ssvNetworkConfig.RegistryContractAddr, executionclient.WithLoggerMulti(logger), executionclient.WithFollowDistanceMulti(executionclient.DefaultFollowDistance), executionclient.WithConnectionTimeoutMulti(cfg.ExecutionClient.ConnectionTimeout), @@ -419,6 +421,13 @@ var StartNodeCmd = &cobra.Command{ storageMap.Add(storageRole, s) } + slotTickerProvider := func() slotticker.SlotTicker { + return slotticker.New(logger, slotticker.Config{ + SlotDuration: networkConfig.SlotDuration, + GenesisTime: networkConfig.GenesisTime, + }) + } + if cfg.SSVOptions.ValidatorOptions.Exporter { retain := cfg.SSVOptions.ValidatorOptions.ExporterRetainSlots threshold := cfg.SSVOptions.NetworkConfig.EstimatedCurrentSlot() @@ -704,7 +713,6 @@ func validateConfig(nodeStorage operatorstorage.Storage, networkName string, usi return fmt.Errorf("incompatible config change: %w", err) } } else { - if err := nodeStorage.SaveConfig(nil, currentConfig); err != nil { return fmt.Errorf("failed to store config: %w", err) } @@ -891,30 +899,41 @@ func ensureOperatorPubKey(nodeStorage operatorstorage.Storage, operatorPubKeyBas return nil } -func setupSSVNetwork(logger *zap.Logger) (networkconfig.NetworkConfig, error) { - networkConfig, err := networkconfig.GetNetworkConfigByName(cfg.SSVOptions.NetworkName) - if err != nil { - return networkconfig.NetworkConfig{}, err +func setupSSVNetwork(logger *zap.Logger) (networkconfig.SSVConfig, error) { + var ssvConfig networkconfig.SSVConfig + + if cfg.SSVOptions.CustomNetwork != nil { + ssvConfig = *cfg.SSVOptions.CustomNetwork + logger.Info("using custom network config") + } else if cfg.SSVOptions.NetworkName != "" { + snc, err := networkconfig.GetSSVConfigByName(cfg.SSVOptions.NetworkName) + if err != nil { + return ssvConfig, err + } + ssvConfig = snc + logger.Info("found network config by name", + zap.String("name", cfg.SSVOptions.NetworkName), + ) } if cfg.SSVOptions.CustomDomainType != "" { if !strings.HasPrefix(cfg.SSVOptions.CustomDomainType, "0x") { - return networkconfig.NetworkConfig{}, errors.New("custom domain type must be a hex string") + return networkconfig.SSVConfig{}, errors.New("custom domain type must be a hex string") } domainBytes, err := hex.DecodeString(cfg.SSVOptions.CustomDomainType[2:]) if err != nil { - return networkconfig.NetworkConfig{}, errors.Wrap(err, "failed to decode custom domain type") + return networkconfig.SSVConfig{}, errors.Wrap(err, "failed to decode custom domain type") } if len(domainBytes) != 4 { - return networkconfig.NetworkConfig{}, errors.New("custom domain type must be 4 bytes") + return networkconfig.SSVConfig{}, errors.New("custom domain type must be 4 bytes") } // https://github.com/ssvlabs/ssv/pull/1808 incremented the post-fork domain type by 1, so we have to maintain the compatibility. postForkDomain := binary.BigEndian.Uint32(domainBytes) + 1 - binary.BigEndian.PutUint32(networkConfig.DomainType[:], postForkDomain) + binary.BigEndian.PutUint32(ssvConfig.DomainType[:], postForkDomain) - logger.Info("running with custom domain type", - fields.Domain(networkConfig.DomainType), + logger.Warn("running with custom domain type; it's deprecated, consider using custom network instead", + fields.Domain(ssvConfig.DomainType), ) } @@ -924,14 +943,12 @@ func setupSSVNetwork(logger *zap.Logger) (networkconfig.NetworkConfig, error) { } logger.Info("setting ssv network", - fields.Network(networkConfig.Name), - fields.Domain(networkConfig.DomainType), + zap.Any("config", ssvConfig), zap.String("nodeType", nodeType), - zap.Any("beaconNetwork", networkConfig.GetBeaconName()), - zap.String("registryContract", networkConfig.RegistryContractAddr), + zap.String("registryContract", ssvConfig.RegistryContractAddr.String()), ) - return networkConfig, nil + return ssvConfig, nil } func setupP2P(logger *zap.Logger, db basedb.Database) network.P2PNetwork { @@ -949,20 +966,6 @@ func setupP2P(logger *zap.Logger, db basedb.Database) network.P2PNetwork { return n } -func setupConsensusClient( - ctx context.Context, - logger *zap.Logger, - slotTickerProvider slotticker.Provider, -) *goclient.GoClient { - cl, err := goclient.New(ctx, logger, cfg.ConsensusClient, slotTickerProvider) - if err != nil { - logger.Fatal("failed to create beacon go-client", zap.Error(err), - fields.Address(cfg.ConsensusClient.BeaconNodeAddr)) - } - - return cl -} - // syncContractEvents blocks until historical events are synced and then spawns a goroutine syncing ongoing events. func syncContractEvents( ctx context.Context, diff --git a/exporter/api/query_handlers_test.go b/exporter/api/query_handlers_test.go index 1f4b79a47f..e86cd246fd 100644 --- a/exporter/api/query_handlers_test.go +++ b/exporter/api/query_handlers_test.go @@ -105,7 +105,7 @@ func TestHandleDecidedQuery(t *testing.T) { for _, role := range roles { pk := sks[1].GetPublicKey() - networkConfig, err := networkconfig.GetNetworkConfigByName(networkconfig.HoleskyStage.Name) + ssvConfig, err := networkconfig.GetSSVConfigByName(networkconfig.HoleskyStage.Name) require.NoError(t, err) decided250Seq, err := protocoltesting.CreateMultipleStoredInstances(rsaKeys, specqbft.Height(0), specqbft.Height(250), func(height specqbft.Height) ([]spectypes.OperatorID, *specqbft.Message) { return oids, &specqbft.Message{ @@ -131,7 +131,7 @@ func TestHandleDecidedQuery(t *testing.T) { t.Run("valid range", func(t *testing.T) { nm := newParticipantsAPIMsg(pk.SerializeToHexStr(), spectypes.BNRoleAttester, 0, 250) h := NewHandler(l) - h.HandleParticipantsQuery(ibftStorage, nm, networkConfig.DomainType) + h.HandleParticipantsQuery(ibftStorage, nm, ssvConfig.DomainType) require.NotNil(t, nm.Msg.Data) msgs, ok := nm.Msg.Data.([]*ParticipantsAPI) @@ -142,7 +142,7 @@ func TestHandleDecidedQuery(t *testing.T) { t.Run("invalid range", func(t *testing.T) { nm := newParticipantsAPIMsg(pk.SerializeToHexStr(), spectypes.BNRoleAttester, 400, 404) h := NewHandler(l) - h.HandleParticipantsQuery(ibftStorage, nm, networkConfig.DomainType) + h.HandleParticipantsQuery(ibftStorage, nm, ssvConfig.DomainType) require.NotNil(t, nm.Msg.Data) data, ok := nm.Msg.Data.([]string) require.True(t, ok) @@ -152,7 +152,7 @@ func TestHandleDecidedQuery(t *testing.T) { t.Run("non-existing validator", func(t *testing.T) { nm := newParticipantsAPIMsg("xxx", spectypes.BNRoleAttester, 400, 404) h := NewHandler(l) - h.HandleParticipantsQuery(ibftStorage, nm, networkConfig.DomainType) + h.HandleParticipantsQuery(ibftStorage, nm, ssvConfig.DomainType) require.NotNil(t, nm.Msg.Data) errs, ok := nm.Msg.Data.([]string) require.True(t, ok) @@ -162,7 +162,7 @@ func TestHandleDecidedQuery(t *testing.T) { t.Run("non-existing role", func(t *testing.T) { nm := newParticipantsAPIMsg(pk.SerializeToHexStr(), math.MaxUint64, 0, 250) h := NewHandler(l) - h.HandleParticipantsQuery(ibftStorage, nm, networkConfig.DomainType) + h.HandleParticipantsQuery(ibftStorage, nm, ssvConfig.DomainType) require.NotNil(t, nm.Msg.Data) errs, ok := nm.Msg.Data.([]string) require.True(t, ok) @@ -172,7 +172,7 @@ func TestHandleDecidedQuery(t *testing.T) { t.Run("non-existing storage", func(t *testing.T) { nm := newParticipantsAPIMsg(pk.SerializeToHexStr(), spectypes.BNRoleSyncCommitteeContribution, 0, 250) h := NewHandler(l) - h.HandleParticipantsQuery(ibftStorage, nm, networkConfig.DomainType) + h.HandleParticipantsQuery(ibftStorage, nm, ssvConfig.DomainType) require.NotNil(t, nm.Msg.Data) errs, ok := nm.Msg.Data.([]string) require.True(t, ok) diff --git a/migrations/migration_4_configlock_add_alan_fork_to_network_name.go b/migrations/migration_4_configlock_add_alan_fork_to_network_name.go index 6a49712c01..03e99cc5f9 100644 --- a/migrations/migration_4_configlock_add_alan_fork_to_network_name.go +++ b/migrations/migration_4_configlock_add_alan_fork_to_network_name.go @@ -6,7 +6,6 @@ import ( "go.uber.org/zap" - "github.com/ssvlabs/ssv/networkconfig" "github.com/ssvlabs/ssv/storage/basedb" ) @@ -27,12 +26,6 @@ var migration_4_configlock_add_alan_fork_to_network_name = Migration{ // If config is not found, it means the node is not initialized yet if found { - networkConfig, err := networkconfig.GetNetworkConfigByName(config.NetworkName) - if err != nil { - return fmt.Errorf("failed to get network config by name: %w", err) - } - - config.NetworkName = networkConfig.NetworkName() if err := nodeStorage.SaveConfig(txn, config); err != nil { return fmt.Errorf("failed to save config: %w", err) } diff --git a/networkconfig/beacon.go b/networkconfig/beacon.go index a520e70c2a..c014076bba 100644 --- a/networkconfig/beacon.go +++ b/networkconfig/beacon.go @@ -1,6 +1,7 @@ package networkconfig import ( + "encoding/json" "fmt" "math" "time" @@ -40,6 +41,15 @@ type BeaconConfig struct { GenesisTime time.Time } +func (b BeaconConfig) String() string { + marshaled, err := json.Marshal(b) + if err != nil { + panic(err) + } + + return string(marshaled) +} + // GetSlotStartTime returns the start time for the given slot func (b BeaconConfig) GetSlotStartTime(slot phase0.Slot) time.Time { if slot > math.MaxInt64 { diff --git a/networkconfig/holesky-e2e.go b/networkconfig/holesky-e2e.go index f464cc13cc..a879cca299 100644 --- a/networkconfig/holesky-e2e.go +++ b/networkconfig/holesky-e2e.go @@ -4,6 +4,7 @@ import ( "math/big" "time" + ethcommon "github.com/ethereum/go-ethereum/common" spectypes "github.com/ssvlabs/ssv-spec/types" ) @@ -18,7 +19,7 @@ var HoleskyE2E = NetworkConfig{ }, SSVConfig: SSVConfig{ DomainType: spectypes.DomainType{0x0, 0x0, 0xee, 0x1}, - RegistryContractAddr: "0x58410bef803ecd7e63b23664c586a6db72daf59c", + RegistryContractAddr: ethcommon.HexToAddress("0x58410bef803ecd7e63b23664c586a6db72daf59c"), RegistrySyncOffset: big.NewInt(405579), Bootnodes: []string{}, }, diff --git a/networkconfig/holesky-stage.go b/networkconfig/holesky-stage.go index 97aa69c291..a28cc3cf72 100644 --- a/networkconfig/holesky-stage.go +++ b/networkconfig/holesky-stage.go @@ -4,6 +4,7 @@ import ( "math/big" "time" + ethcommon "github.com/ethereum/go-ethereum/common" spectypes "github.com/ssvlabs/ssv-spec/types" ) @@ -19,7 +20,7 @@ var HoleskyStage = NetworkConfig{ SSVConfig: SSVConfig{ DomainType: [4]byte{0x00, 0x00, 0x31, 0x13}, RegistrySyncOffset: new(big.Int).SetInt64(84599), - RegistryContractAddr: "0x0d33801785340072C452b994496B19f196b7eE15", + RegistryContractAddr: ethcommon.HexToAddress("0x0d33801785340072C452b994496B19f196b7eE15"), DiscoveryProtocolID: [6]byte{'s', 's', 'v', 'd', 'v', '5'}, Bootnodes: []string{ // Public bootnode: diff --git a/networkconfig/holesky.go b/networkconfig/holesky.go index 4629c7ce34..801d9c3cad 100644 --- a/networkconfig/holesky.go +++ b/networkconfig/holesky.go @@ -4,6 +4,7 @@ import ( "math/big" "time" + ethcommon "github.com/ethereum/go-ethereum/common" spectypes "github.com/ssvlabs/ssv-spec/types" ) @@ -19,7 +20,7 @@ var Holesky = NetworkConfig{ SSVConfig: SSVConfig{ DomainType: spectypes.DomainType{0x0, 0x0, 0x5, 0x2}, RegistrySyncOffset: new(big.Int).SetInt64(181612), - RegistryContractAddr: "0x38A4794cCEd47d3baf7370CcC43B560D3a1beEFA", + RegistryContractAddr: ethcommon.HexToAddress("0x38A4794cCEd47d3baf7370CcC43B560D3a1beEFA"), DiscoveryProtocolID: [6]byte{'s', 's', 'v', 'd', 'v', '5'}, Bootnodes: []string{ // SSV Labs diff --git a/networkconfig/hoodi-stage.go b/networkconfig/hoodi-stage.go index e28511f599..42ffc5d917 100644 --- a/networkconfig/hoodi-stage.go +++ b/networkconfig/hoodi-stage.go @@ -4,6 +4,7 @@ import ( "math/big" "time" + ethcommon "github.com/ethereum/go-ethereum/common" spectypes "github.com/ssvlabs/ssv-spec/types" ) @@ -19,7 +20,7 @@ var HoodiStage = NetworkConfig{ SSVConfig: SSVConfig{ DomainType: [4]byte{0x00, 0x00, 0x31, 0x14}, RegistrySyncOffset: new(big.Int).SetInt64(1004), - RegistryContractAddr: "0x0aaace4e8affc47c6834171c88d342a4abd8f105", + RegistryContractAddr: ethcommon.HexToAddress("0x0aaace4e8affc47c6834171c88d342a4abd8f105"), DiscoveryProtocolID: [6]byte{'s', 's', 'v', 'd', 'v', '5'}, Bootnodes: []string{ // SSV Labs diff --git a/networkconfig/hoodi.go b/networkconfig/hoodi.go index 03799d694b..78f0263743 100644 --- a/networkconfig/hoodi.go +++ b/networkconfig/hoodi.go @@ -4,6 +4,7 @@ import ( "math/big" "time" + ethcommon "github.com/ethereum/go-ethereum/common" spectypes "github.com/ssvlabs/ssv-spec/types" ) @@ -19,7 +20,7 @@ var Hoodi = NetworkConfig{ SSVConfig: SSVConfig{ DomainType: spectypes.DomainType{0x0, 0x0, 0x5, 0x3}, RegistrySyncOffset: new(big.Int).SetInt64(1065), - RegistryContractAddr: "0x58410Bef803ECd7E63B23664C586A6DB72DAf59c", + RegistryContractAddr: ethcommon.HexToAddress("0x58410Bef803ECd7E63B23664C586A6DB72DAf59c"), DiscoveryProtocolID: [6]byte{'s', 's', 'v', 'd', 'v', '5'}, Bootnodes: []string{ // SSV Labs diff --git a/networkconfig/local-testnet.go b/networkconfig/local-testnet.go index bd94686743..eb998639f9 100644 --- a/networkconfig/local-testnet.go +++ b/networkconfig/local-testnet.go @@ -1,8 +1,10 @@ package networkconfig import ( + "math/big" "time" + ethcommon "github.com/ethereum/go-ethereum/common" spectypes "github.com/ssvlabs/ssv-spec/types" ) @@ -17,9 +19,11 @@ var LocalTestnet = NetworkConfig{ }, SSVConfig: SSVConfig{ DomainType: spectypes.DomainType{0x0, 0x0, spectypes.JatoV2NetworkID.Byte(), 0x2}, - RegistryContractAddr: "0xC3CD9A0aE89Fff83b71b58b6512D43F8a41f363D", + RegistrySyncOffset: new(big.Int).SetInt64(0), + RegistryContractAddr: ethcommon.HexToAddress("0xC3CD9A0aE89Fff83b71b58b6512D43F8a41f363D"), Bootnodes: []string{ "enr:-Li4QLR4Y1VbwiqFYKy6m-WFHRNDjhMDZ_qJwIABu2PY9BHjIYwCKpTvvkVmZhu43Q6zVA29sEUhtz10rQjDJkK3Hd-GAYiGrW2Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhCLdu_SJc2VjcDI1NmsxoQJTcI7GHPw-ZqIflPZYYDK_guurp_gsAFF5Erns3-PAvIN0Y3CCE4mDdWRwgg-h", }, + DiscoveryProtocolID: [6]byte{'s', 's', 'v', 'd', 'v', '5'}, }, } diff --git a/networkconfig/mainnet.go b/networkconfig/mainnet.go index d82490ce27..1e475cd140 100644 --- a/networkconfig/mainnet.go +++ b/networkconfig/mainnet.go @@ -4,6 +4,7 @@ import ( "math/big" "time" + ethcommon "github.com/ethereum/go-ethereum/common" spectypes "github.com/ssvlabs/ssv-spec/types" ) @@ -19,7 +20,7 @@ var Mainnet = NetworkConfig{ SSVConfig: SSVConfig{ DomainType: spectypes.AlanMainnet, RegistrySyncOffset: new(big.Int).SetInt64(17507487), - RegistryContractAddr: "0xDD9BC35aE942eF0cFa76930954a156B3fF30a4E1", + RegistryContractAddr: ethcommon.HexToAddress("0xDD9BC35aE942eF0cFa76930954a156B3fF30a4E1"), DiscoveryProtocolID: [6]byte{'s', 's', 'v', 'd', 'v', '5'}, Bootnodes: []string{ // SSV Labs diff --git a/networkconfig/network.go b/networkconfig/network.go index e70bf5f839..cafc335ea1 100644 --- a/networkconfig/network.go +++ b/networkconfig/network.go @@ -7,27 +7,8 @@ import ( //go:generate go tool -modfile=../tool.mod mockgen -package=networkconfig -destination=./network_mock.go -source=./network.go -var SupportedConfigs = map[string]NetworkConfig{ - Mainnet.Name: Mainnet, - Holesky.Name: Holesky, - HoleskyStage.Name: HoleskyStage, - LocalTestnet.Name: LocalTestnet, - HoleskyE2E.Name: HoleskyE2E, - Hoodi.Name: Hoodi, - HoodiStage.Name: HoodiStage, - Sepolia.Name: Sepolia, -} - const forkName = "alan" -func GetNetworkConfigByName(name string) (NetworkConfig, error) { - if network, ok := SupportedConfigs[name]; ok { - return network, nil - } - - return NetworkConfig{}, fmt.Errorf("network not supported: %v", name) -} - type Network interface { NetworkName() string Beacon @@ -41,12 +22,12 @@ type NetworkConfig struct { } func (n NetworkConfig) String() string { - b, err := json.MarshalIndent(n, "", "\t") + jsonBytes, err := json.Marshal(n) if err != nil { - return "" + panic(err) } - return string(b) + return string(jsonBytes) } func (n NetworkConfig) NetworkName() string { diff --git a/networkconfig/sepolia.go b/networkconfig/sepolia.go index 49c0066177..02ea111d2e 100644 --- a/networkconfig/sepolia.go +++ b/networkconfig/sepolia.go @@ -4,6 +4,7 @@ import ( "math/big" "time" + ethcommon "github.com/ethereum/go-ethereum/common" spectypes "github.com/ssvlabs/ssv-spec/types" ) @@ -19,7 +20,7 @@ var Sepolia = NetworkConfig{ SSVConfig: SSVConfig{ DomainType: spectypes.DomainType{0x0, 0x0, 0x5, 0x69}, RegistrySyncOffset: new(big.Int).SetInt64(7795814), - RegistryContractAddr: "0x261419B48F36EdF420743E9f91bABF4856e76f99", + RegistryContractAddr: ethcommon.HexToAddress("0x261419B48F36EdF420743E9f91bABF4856e76f99"), DiscoveryProtocolID: [6]byte{'s', 's', 'v', 'd', 'v', '5'}, Bootnodes: []string{ // SSV Labs diff --git a/networkconfig/ssv.go b/networkconfig/ssv.go index 50ae99370e..61d3d18959 100644 --- a/networkconfig/ssv.go +++ b/networkconfig/ssv.go @@ -1,13 +1,36 @@ package networkconfig import ( + "encoding/json" + "fmt" "math/big" + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" spectypes "github.com/ssvlabs/ssv-spec/types" ) //go:generate go tool -modfile=../tool.mod mockgen -package=networkconfig -destination=./ssv_mock.go -source=./ssv.go +var supportedSSVConfigs = map[string]SSVConfig{ + Mainnet.Name: Mainnet.SSVConfig, + Holesky.Name: Holesky.SSVConfig, + HoleskyStage.Name: HoleskyStage.SSVConfig, + LocalTestnet.Name: LocalTestnet.SSVConfig, + HoleskyE2E.Name: HoleskyE2E.SSVConfig, + Hoodi.Name: Hoodi.SSVConfig, + HoodiStage.Name: HoodiStage.SSVConfig, + Sepolia.Name: Sepolia.SSVConfig, +} + +func GetSSVConfigByName(name string) (SSVConfig, error) { + if network, ok := supportedSSVConfigs[name]; ok { + return network, nil + } + + return SSVConfig{}, fmt.Errorf("network not supported: %v", name) +} + type SSV interface { GetDomainType() spectypes.DomainType } @@ -15,11 +38,88 @@ type SSV interface { type SSVConfig struct { DomainType spectypes.DomainType RegistrySyncOffset *big.Int - RegistryContractAddr string // TODO: ethcommon.Address + RegistryContractAddr ethcommon.Address Bootnodes []string DiscoveryProtocolID [6]byte } -func (ssv SSVConfig) GetDomainType() spectypes.DomainType { - return ssv.DomainType +func (s SSVConfig) String() string { + marshaled, err := json.Marshal(s) + if err != nil { + panic(err) + } + + return string(marshaled) +} + +type marshaledConfig struct { + DomainType hexutil.Bytes `json:"DomainType,omitempty" yaml:"DomainType,omitempty"` + RegistrySyncOffset *big.Int `json:"RegistrySyncOffset,omitempty" yaml:"RegistrySyncOffset,omitempty"` + RegistryContractAddr ethcommon.Address `json:"RegistryContractAddr,omitempty" yaml:"RegistryContractAddr,omitempty"` + Bootnodes []string `json:"Bootnodes,omitempty" yaml:"Bootnodes,omitempty"` + DiscoveryProtocolID hexutil.Bytes `json:"DiscoveryProtocolID,omitempty" yaml:"DiscoveryProtocolID,omitempty"` +} + +// Helper method to avoid duplication between MarshalJSON and MarshalYAML +func (s SSVConfig) marshal() marshaledConfig { + aux := marshaledConfig{ + DomainType: s.DomainType[:], + RegistrySyncOffset: s.RegistrySyncOffset, + RegistryContractAddr: s.RegistryContractAddr, + Bootnodes: s.Bootnodes, + DiscoveryProtocolID: s.DiscoveryProtocolID[:], + } + + return aux +} + +func (s SSVConfig) MarshalJSON() ([]byte, error) { + return json.Marshal(s.marshal()) +} + +func (s SSVConfig) MarshalYAML() (interface{}, error) { + return s.marshal(), nil +} + +// Helper method to avoid duplication between UnmarshalJSON and UnmarshalYAML +func (s *SSVConfig) unmarshalFromConfig(aux marshaledConfig) error { + if len(aux.DomainType) != 4 { + return fmt.Errorf("invalid domain type length: expected 4 bytes, got %d", len(aux.DomainType)) + } + + if len(aux.DiscoveryProtocolID) != 6 { + return fmt.Errorf("invalid discovery protocol ID length: expected 6 bytes, got %d", len(aux.DiscoveryProtocolID)) + } + + *s = SSVConfig{ + DomainType: spectypes.DomainType(aux.DomainType), + RegistrySyncOffset: aux.RegistrySyncOffset, + RegistryContractAddr: aux.RegistryContractAddr, + Bootnodes: aux.Bootnodes, + DiscoveryProtocolID: [6]byte(aux.DiscoveryProtocolID), + } + + return nil +} + +func (s *SSVConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + var aux marshaledConfig + if err := unmarshal(&aux); err != nil { + return err + } + + return s.unmarshalFromConfig(aux) +} + +func (s *SSVConfig) UnmarshalJSON(data []byte) error { + var aux marshaledConfig + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + return s.unmarshalFromConfig(aux) +} + +func (s SSVConfig) GetDomainType() spectypes.DomainType { + return s.DomainType } diff --git a/networkconfig/ssv_test.go b/networkconfig/ssv_test.go new file mode 100644 index 0000000000..8872d6b4a3 --- /dev/null +++ b/networkconfig/ssv_test.go @@ -0,0 +1,232 @@ +package networkconfig + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "math/big" + "reflect" + "sort" + "testing" + + ethcommon "github.com/ethereum/go-ethereum/common" + spectypes "github.com/ssvlabs/ssv-spec/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestSSVConfig_MarshalUnmarshalJSON(t *testing.T) { + // Create a sample SSVConfig + originalConfig := SSVConfig{ + DomainType: spectypes.DomainType{0x01, 0x02, 0x03, 0x04}, + RegistrySyncOffset: big.NewInt(123456), + RegistryContractAddr: ethcommon.HexToAddress("0x123456789abcdef0123456789abcdef012345678"), + Bootnodes: []string{"bootnode1", "bootnode2"}, + DiscoveryProtocolID: [6]byte{0x05, 0x06, 0x07, 0x08, 0x09, 0x0a}, + } + + // Marshal to JSON + jsonBytes, err := json.Marshal(originalConfig) + require.NoError(t, err) + + // Unmarshal from JSON + var unmarshaledConfig SSVConfig + err = json.Unmarshal(jsonBytes, &unmarshaledConfig) + require.NoError(t, err) + + // Marshal again after unmarshaling + remarshaledBytes, err := json.Marshal(unmarshaledConfig) + require.NoError(t, err) + + // Compare the original and remarshaled JSON bytes + assert.JSONEq(t, string(jsonBytes), string(remarshaledBytes)) + + // Compare the original and unmarshaled structs + assert.Equal(t, originalConfig.DomainType, unmarshaledConfig.DomainType) + assert.Equal(t, originalConfig.RegistrySyncOffset.Int64(), unmarshaledConfig.RegistrySyncOffset.Int64()) + assert.Equal(t, originalConfig.RegistryContractAddr, unmarshaledConfig.RegistryContractAddr) + assert.Equal(t, originalConfig.Bootnodes, unmarshaledConfig.Bootnodes) + assert.Equal(t, originalConfig.DiscoveryProtocolID, unmarshaledConfig.DiscoveryProtocolID) +} + +func TestSSVConfig_MarshalUnmarshalYAML(t *testing.T) { + // Create a sample SSVConfig + originalConfig := SSVConfig{ + DomainType: spectypes.DomainType{0x01, 0x02, 0x03, 0x04}, + RegistrySyncOffset: big.NewInt(123456), + RegistryContractAddr: ethcommon.HexToAddress("0x123456789abcdef0123456789abcdef012345678"), + Bootnodes: []string{"bootnode1", "bootnode2"}, + DiscoveryProtocolID: [6]byte{0x05, 0x06, 0x07, 0x08, 0x09, 0x0a}, + } + + // Marshal to YAML + yamlBytes, err := yaml.Marshal(originalConfig) + require.NoError(t, err) + + // Unmarshal from YAML + var unmarshaledConfig SSVConfig + err = yaml.Unmarshal(yamlBytes, &unmarshaledConfig) + require.NoError(t, err) + + // Marshal again after unmarshaling + remarshaledBytes, err := yaml.Marshal(unmarshaledConfig) + require.NoError(t, err) + + // Compare the original and unmarshaled structs + assert.Equal(t, originalConfig.DomainType, unmarshaledConfig.DomainType) + assert.Equal(t, originalConfig.RegistrySyncOffset.Int64(), unmarshaledConfig.RegistrySyncOffset.Int64()) + assert.Equal(t, originalConfig.RegistryContractAddr, unmarshaledConfig.RegistryContractAddr) + assert.Equal(t, originalConfig.Bootnodes, unmarshaledConfig.Bootnodes) + assert.Equal(t, originalConfig.DiscoveryProtocolID, unmarshaledConfig.DiscoveryProtocolID) + + // Compare the original and remarshaled YAML bytes + // YAML doesn't preserve order by default, so we need to compare the unmarshaled content + var originalYAMLMap map[string]interface{} + var remarshaledYAMLMap map[string]interface{} + + err = yaml.Unmarshal(yamlBytes, &originalYAMLMap) + require.NoError(t, err) + + err = yaml.Unmarshal(remarshaledBytes, &remarshaledYAMLMap) + require.NoError(t, err) + + assert.Equal(t, originalYAMLMap, remarshaledYAMLMap) +} + +// hashStructJSON creates a deterministic hash of a struct by marshaling to sorted JSON +func hashStructJSON(v interface{}) (string, error) { + // Create a JSON encoder that sorts map keys + buffer := &bytes.Buffer{} + encoder := json.NewEncoder(buffer) + encoder.SetIndent("", "") + encoder.SetEscapeHTML(false) + + if err := encoder.Encode(v); err != nil { + return "", err + } + + // Compute SHA-256 hash + hasher := sha256.New() + hasher.Write(buffer.Bytes()) + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +// TestFieldPreservation ensures that all fields are properly preserved during +// marshal/unmarshal operations and that we can detect changes to the struct +func TestFieldPreservation(t *testing.T) { + t.Run("test all fields are present after marshaling", func(t *testing.T) { + // Get all field names from SSVConfig + configType := reflect.TypeOf(SSVConfig{}) + marshaledType := reflect.TypeOf(marshaledConfig{}) + + var configFields, marshaledFields []string + + for i := 0; i < configType.NumField(); i++ { + configFields = append(configFields, configType.Field(i).Name) + } + + for i := 0; i < marshaledType.NumField(); i++ { + marshaledFields = append(marshaledFields, marshaledType.Field(i).Name) + } + + // Sort fields for deterministic comparison + sort.Strings(configFields) + sort.Strings(marshaledFields) + + // Ensure the same fields exist in both structs + assert.Equal(t, configFields, marshaledFields, "SSVConfig and marshaledConfig should have the same fields") + }) + + t.Run("hash comparison JSON", func(t *testing.T) { + // Create a sample config + config := SSVConfig{ + DomainType: spectypes.DomainType{0x01, 0x02, 0x03, 0x04}, + RegistrySyncOffset: big.NewInt(123456), + RegistryContractAddr: ethcommon.HexToAddress("0x123456789abcdef0123456789abcdef012345678"), + Bootnodes: []string{"bootnode1", "bootnode2"}, + DiscoveryProtocolID: [6]byte{0x05, 0x06, 0x07, 0x08, 0x09, 0x0a}, + } + + // Marshal and unmarshal to test preservation + jsonBytes, err := json.Marshal(config) + require.NoError(t, err) + + var unmarshaled SSVConfig + err = json.Unmarshal(jsonBytes, &unmarshaled) + require.NoError(t, err) + + // Hash the original and unmarshaled struct + originalHash, err := hashStructJSON(config) + require.NoError(t, err) + + unmarshaledHash, err := hashStructJSON(unmarshaled) + require.NoError(t, err) + + // The hashes should match if all fields are preserved + assert.Equal(t, originalHash, unmarshaledHash, "Hash mismatch indicates fields weren't properly preserved in JSON") + + // Store the expected hash - this will fail if a new field is added without updating the tests + expectedJSONHash := "3afe88f355185266dfd842df18a096ea8f40dd28f8b022710aedca1d09d59cef" + assert.Equal(t, expectedJSONHash, originalHash, + "Hash has changed. If you've added a new field, please update the expected hash in this test.") + }) + + t.Run("hash comparison YAML", func(t *testing.T) { + // Create a sample config + config := SSVConfig{ + DomainType: spectypes.DomainType{0x01, 0x02, 0x03, 0x04}, + RegistrySyncOffset: big.NewInt(123456), + RegistryContractAddr: ethcommon.HexToAddress("0x123456789abcdef0123456789abcdef012345678"), + Bootnodes: []string{"bootnode1", "bootnode2"}, + DiscoveryProtocolID: [6]byte{0x05, 0x06, 0x07, 0x08, 0x09, 0x0a}, + } + + // Marshal and unmarshal to test preservation + yamlBytes, err := yaml.Marshal(config) + require.NoError(t, err) + + var unmarshaled SSVConfig + err = yaml.Unmarshal(yamlBytes, &unmarshaled) + require.NoError(t, err) + + // For YAML, convert to JSON for consistent hashing + originalHash, err := hashStructJSON(config) + require.NoError(t, err) + + unmarshaledHash, err := hashStructJSON(unmarshaled) + require.NoError(t, err) + + // The hashes should match if all fields are preserved + assert.Equal(t, originalHash, unmarshaledHash, "Hash mismatch indicates fields weren't properly preserved in YAML") + }) +} + +// TestExistingNetworkConfigs validates that all predefined network configs +// can be marshaled and unmarshaled correctly +func TestExistingNetworkConfigs(t *testing.T) { + for networkName, config := range supportedSSVConfigs { + t.Run(networkName, func(t *testing.T) { + // JSON test + jsonBytes, err := json.Marshal(config) + require.NoError(t, err) + + var jsonUnmarshaled SSVConfig + err = json.Unmarshal(jsonBytes, &jsonUnmarshaled) + require.NoError(t, err) + + assert.Equal(t, config.DomainType, jsonUnmarshaled.DomainType) + + // YAML test + yamlBytes, err := yaml.Marshal(config) + require.NoError(t, err) + + var yamlUnmarshaled SSVConfig + err = yaml.Unmarshal(yamlBytes, &yamlUnmarshaled) + require.NoError(t, err) + + assert.Equal(t, config.DomainType, yamlUnmarshaled.DomainType) + }) + } +} diff --git a/networkconfig/test-network.go b/networkconfig/test-network.go index 9a3ee5ceff..34e6e6fac2 100644 --- a/networkconfig/test-network.go +++ b/networkconfig/test-network.go @@ -4,6 +4,7 @@ import ( "math/big" "time" + ethcommon "github.com/ethereum/go-ethereum/common" spectypes "github.com/ssvlabs/ssv-spec/types" ) @@ -19,7 +20,7 @@ var TestNetwork = NetworkConfig{ SSVConfig: SSVConfig{ DomainType: spectypes.DomainType{0x0, 0x0, spectypes.JatoNetworkID.Byte(), 0x2}, RegistrySyncOffset: new(big.Int).SetInt64(9015219), - RegistryContractAddr: "0x4B133c68A084B8A88f72eDCd7944B69c8D545f03", + RegistryContractAddr: ethcommon.HexToAddress("0x4B133c68A084B8A88f72eDCd7944B69c8D545f03"), Bootnodes: []string{ "enr:-Li4QFIQzamdvTxGJhvcXG_DFmCeyggSffDnllY5DiU47pd_K_1MRnSaJimWtfKJ-MD46jUX9TwgW5Jqe0t4pH41RYWGAYuFnlyth2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhCLdu_SJc2VjcDI1NmsxoQN4v-N9zFYwEqzGPBBX37q24QPFvAVUtokIo1fblIsmTIN0Y3CCE4uDdWRwgg-j", }, diff --git a/operator/node.go b/operator/node.go index 0fcf7f1225..278e812c73 100644 --- a/operator/node.go +++ b/operator/node.go @@ -26,9 +26,9 @@ import ( // Options contains options to create the node type Options struct { - // NetworkName is the network name of this node - NetworkName string `yaml:"Network" env:"NETWORK" env-default:"mainnet" env-description:"Ethereum network to connect to (mainnet, holesky, sepolia, etc.)"` - CustomDomainType string `yaml:"CustomDomainType" env:"CUSTOM_DOMAIN_TYPE" env-default:"" env-description:"Override SSV domain type for network isolation. Warning: Please modify only if you are certain of the implications. This would be incremented by 1 after Alan fork (e.g., 0x01020304 → 0x01020305 post-fork)"` + NetworkName string `yaml:"Network" env:"NETWORK" env-default:"mainnet" env-description:"Ethereum network to connect to (mainnet, holesky, sepolia, etc.). For backwards compatibility it's ignored if CustomNetwork is set"` + CustomNetwork *networkconfig.SSVConfig `yaml:"CustomNetwork" env:"CUSTOM_NETWORK" env-description:"Custom SSV network configuration"` + CustomDomainType string `yaml:"CustomDomainType" env:"CUSTOM_DOMAIN_TYPE" env-default:"" env-description:"Override SSV domain type for network isolation. Warning: Please modify only if you are certain of the implications. This would be incremented by 1 after Alan fork (e.g., 0x01020304 → 0x01020305 post-fork)"` // DEPRECATED: use CustomNetwork instead. NetworkConfig networkconfig.NetworkConfig BeaconNode beaconprotocol.BeaconNode // TODO: consider renaming to ConsensusClient ExecutionClient executionclient.Provider diff --git a/protocol/v2/blockchain/beacon/client.go b/protocol/v2/blockchain/beacon/client.go index 05c894898a..18dd29a6f4 100644 --- a/protocol/v2/blockchain/beacon/client.go +++ b/protocol/v2/blockchain/beacon/client.go @@ -11,8 +11,6 @@ import ( "github.com/attestantio/go-eth2-client/spec/bellatrix" "github.com/attestantio/go-eth2-client/spec/phase0" ssz "github.com/ferranbt/fastssz" - - "github.com/ssvlabs/ssv/networkconfig" ) // TODO: add missing tests @@ -142,7 +140,6 @@ type BeaconNode interface { // Options for controller struct creation type Options struct { Context context.Context - BeaconConfig networkconfig.BeaconConfig BeaconNodeAddr string `yaml:"BeaconNodeAddr" env:"BEACON_NODE_ADDR" env-required:"true" env-description:"Beacon node URL(s). Multiple nodes are supported via semicolon-separated URLs (e.g. 'http://localhost:5052;http://localhost:5053')"` SyncDistanceTolerance uint64 `yaml:"SyncDistanceTolerance" env:"BEACON_SYNC_DISTANCE_TOLERANCE" env-default:"4" env-description:"Maximum number of slots behind head considered in-sync"` WithWeightedAttestationData bool `yaml:"WithWeightedAttestationData" env:"WITH_WEIGHTED_ATTESTATION_DATA" env-default:"false" env-description:"Enable attestation data scoring across multiple beacon nodes"` diff --git a/utils/boot_node/node.go b/utils/boot_node/node.go index 77dca55ea0..6debfdd583 100644 --- a/utils/boot_node/node.go +++ b/utils/boot_node/node.go @@ -50,11 +50,11 @@ type bootNode struct { externalIP string tcpPort uint16 dbPath string - network networkconfig.NetworkConfig + ssvConfig networkconfig.SSVConfig } // New is the constructor of ssvNode -func New(logger *zap.Logger, networkConfig networkconfig.NetworkConfig, opts Options) (Node, error) { +func New(logger *zap.Logger, ssvConfig networkconfig.SSVConfig, opts Options) (Node, error) { return &bootNode{ logger: logger.Named(logging.NameBootNode), privateKey: opts.PrivateKey, @@ -63,7 +63,7 @@ func New(logger *zap.Logger, networkConfig networkconfig.NetworkConfig, opts Opt externalIP: opts.ExternalIP, tcpPort: opts.TCPPort, dbPath: opts.DbPath, - network: networkConfig, + ssvConfig: ssvConfig, }, nil } @@ -110,9 +110,9 @@ func (n *bootNode) Start(ctx context.Context) error { listener := n.createListener(ipAddr, n.discv5port, privKey) node := listener.LocalNode().Node() n.logger.Info("Running", - zap.String("node", node.String()), - zap.String("network", n.network.Name), - fields.ProtocolID(n.network.DiscoveryProtocolID), + zap.Stringer("node", node), + zap.Stringer("config", n.ssvConfig), + fields.ProtocolID(n.ssvConfig.DiscoveryProtocolID), ) handler := &handler{ @@ -171,7 +171,7 @@ func (n *bootNode) createListener(ipAddr string, port uint16, privateKey *ecdsa. listener, err := discover.ListenV5(conn, localNode, discover.Config{ PrivateKey: privateKey, - V5ProtocolID: &n.network.DiscoveryProtocolID, + V5ProtocolID: &n.ssvConfig.DiscoveryProtocolID, }) if err != nil { log.Fatal(err)