From 0c04f7e18b98b90befe8cf6934d01713f5063e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Thu, 30 Jul 2026 14:31:51 +0200 Subject: [PATCH 01/10] operator/validator: document the legacy alan runner fork gating The BooleFork() gate on the RoleAggregator/RoleSyncCommitteeContribution runners is a deliberate share-add-time snapshot: the resulting cross-population asymmetry is bounded to the Boole subsequent window (SlotsPerEpoch + booleSubsequentWindowLateSlots; 34 slots on mainnet) and affects only already-decided pre-fork aggregator stragglers. These runners retire wholesale with the Alan topics. Item 4 of #2968 (decision: document, don't slot-gate). --- operator/validator/controller.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/operator/validator/controller.go b/operator/validator/controller.go index c2461cd42f..10d42b9869 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -1223,6 +1223,15 @@ func SetupRunners( case ssvtypes.RoleAggregator: // Post-Boole, aggregator duties route through the merged AggregatorCommitteeRunner // (committee-scoped) instead of this legacy per-validator runner. + // + // Gating on BooleFork() here is a deliberate snapshot taken at share-add time: once a + // share is added, this runner set is fixed until the share is re-added, so a share added + // shortly before the fork keeps its legacy runner across the transition. The resulting + // cross-population asymmetry (some operators still running the legacy runner while others + // have moved to the committee runner) is bounded to the Boole subsequent window + // (SlotsPerEpoch + booleSubsequentWindowLateSlots; 34 slots on mainnet — see InBooleTransitionWindow) + // and only affects already-decided pre-fork aggregator stragglers — no correctness impact. + // These legacy runners retire wholesale together with the Alan topics. if !options.NetworkConfig.BooleFork() { aggregatorValueChecker := ssv.NewAggregatorChecker(options.NetworkConfig.Beacon, share.ValidatorPubKey, share.ValidatorIndex) runners[role], err = runner.NewAggregatorRunner(runner.AggregatorRunnerOptions{ @@ -1235,6 +1244,15 @@ func SetupRunners( case ssvtypes.RoleSyncCommitteeContribution: // Post-Boole, sync committee contribution duties route through the merged // AggregatorCommitteeRunner (committee-scoped) instead of this legacy per-validator runner. + // + // Gating on BooleFork() here is a deliberate snapshot taken at share-add time: once a + // share is added, this runner set is fixed until the share is re-added, so a share added + // shortly before the fork keeps its legacy runner across the transition. The resulting + // cross-population asymmetry (some operators still running the legacy runner while others + // have moved to the committee runner) is bounded to the Boole subsequent window + // (SlotsPerEpoch + booleSubsequentWindowLateSlots; 34 slots on mainnet — see InBooleTransitionWindow) + // and only affects already-decided pre-fork aggregator stragglers — no correctness impact. + // These legacy runners retire wholesale together with the Alan topics. if !options.NetworkConfig.BooleFork() { syncCommitteeContributionValueChecker := ssv.NewSyncCommitteeContributionChecker(options.NetworkConfig.Beacon, share.ValidatorPubKey, share.ValidatorIndex) runners[role], err = runner.NewSyncCommitteeAggregatorRunner(runner.SyncCommitteeAggregatorRunnerOptions{ From b8a834befdb9ea9e0c23fc5fd836b30834485472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Thu, 30 Jul 2026 14:31:51 +0200 Subject: [PATCH 02/10] networkconfig: validate the fork schedule at startup A garbage forks.boole epoch silently overflowed FirstSlotAtEpoch, and a custom network scheduling Boole without setting next_domain_type "activates" the fork with zero observable domain change (unmarshal defaults the next domain to the current one). Add Network.Validate() - hard error on the overflow and zero-slots-per-epoch cases, operator-actionable warning on the same-domain case - called right after the Network is assembled. Item 6 of #2968. --- cli/operator/node.go | 7 ++++ networkconfig/network.go | 35 ++++++++++++++++ networkconfig/network_test.go | 77 +++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) diff --git a/cli/operator/node.go b/cli/operator/node.go index 07c4ef6e5f..590c14f95b 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -105,6 +105,13 @@ func buildNode(ctx context.Context, cfg *config, logger *zap.Logger) (*node, err SSV: ssvNetworkConfig, Beacon: consensusClient.BeaconConfig(), } + warnings, err := networkConfig.Validate() + if err != nil { + return nil, fmt.Errorf("invalid network config: %w", err) + } + for _, warning := range warnings { + logger.Warn(warning) + } var executionAddrList []string for _, addr := range strings.Split(cfg.ExecutionClient.Addr, ";") { diff --git a/networkconfig/network.go b/networkconfig/network.go index 5dc09b7655..38ba6dec5f 100644 --- a/networkconfig/network.go +++ b/networkconfig/network.go @@ -81,6 +81,41 @@ func (n Network) BooleForkAtSlot(slot phase0.Slot) bool { return n.BooleForkAtEpoch(n.EstimatedEpochAtSlot(slot)) } +// Validate checks the assembled network configuration for internal inconsistencies that fork +// activation logic can't catch on its own. It returns non-fatal warnings for configurations that +// are almost certainly a mistake, and an error for configurations that would panic or misbehave. +// It stays logger-free by design: the caller decides how to surface warnings (e.g. logger.Warn) +// and whether/how to abort on error. +func (n Network) Validate() (warnings []string, err error) { + if n.BooleForkScheduled() { + // Guard the division below the way inBooleSubsequentWindowWithSlots does — but as a hard + // error: a zero SlotsPerEpoch is exactly the malformed-config class Validate exists to + // catch, and would otherwise panic here. + if n.SlotsPerEpoch == 0 { + return nil, fmt.Errorf("slots per epoch must be positive when a boole fork is scheduled") + } + + // Mirrors the overflow guard in inBooleSubsequentWindowWithSlots: FirstSlotAtEpoch(Boole) + // would overflow if the fork epoch is beyond the representable slot range. + maxEpoch := phase0.Epoch(math.MaxUint64 / n.SlotsPerEpoch) + if n.SSV.Forks.Boole > maxEpoch { + return nil, fmt.Errorf("boole fork epoch %d overflows slot conversion (max supported epoch %d)", n.SSV.Forks.Boole, maxEpoch) + } + + // unmarshalFromConfig defaults NextDomainType to DomainType when the field is absent, so + // equality here is exactly the signature of a scheduled fork that forgot to set + // NextDomainType: it would "activate" with zero observable domain change. + if n.NextDomainType == n.DomainType { + warnings = append(warnings, fmt.Sprintf( + "boole fork is scheduled at epoch %d but NextDomainType equals DomainType: the fork would activate with no observable domain change", + n.SSV.Forks.Boole, + )) + } + } + + return warnings, nil +} + // InBooleTransitionWindow checks if the slot is in the Boole transition window, // i.e., in `PRIOR_WINDOW` or `SUBSEQUENT_WINDOW` according to https://github.com/ssvlabs/SIPs/pull/43. func (n Network) InBooleTransitionWindow(slot phase0.Slot) bool { diff --git a/networkconfig/network_test.go b/networkconfig/network_test.go index e227a595cb..aa6068d14c 100644 --- a/networkconfig/network_test.go +++ b/networkconfig/network_test.go @@ -202,6 +202,83 @@ func TestBooleForkAtSlot(t *testing.T) { } } +func TestNetworkValidate(t *testing.T) { + domainAlan := spectypes.DomainType{0x01, 0x02, 0x03, 0x04} + domainBoole := spectypes.DomainType{0x05, 0x06, 0x07, 0x08} + + tests := []struct { + name string + boole phase0.Epoch + slotsPerEpoch uint64 + domainType spectypes.DomainType + nextDomainType spectypes.DomainType + expectErr bool + expectedWarnings int + }{ + { + name: "hard_error_zero_slots_per_epoch", + boole: 10, + slotsPerEpoch: 0, + domainType: domainAlan, + nextDomainType: domainBoole, + expectErr: true, + }, + { + name: "hard_error_overflow", + boole: phase0.Epoch(math.MaxUint64/32) + 1, + slotsPerEpoch: 32, + domainType: domainAlan, + nextDomainType: domainBoole, + expectErr: true, + }, + { + name: "warning_next_domain_equals_domain", + boole: 10, + slotsPerEpoch: 32, + domainType: domainAlan, + nextDomainType: domainAlan, + expectedWarnings: 1, + }, + { + name: "clean_scheduled", + boole: 10, + slotsPerEpoch: 32, + domainType: domainAlan, + nextDomainType: domainBoole, + }, + { + name: "clean_unscheduled", + boole: phase0.Epoch(math.MaxUint64), + slotsPerEpoch: 32, + domainType: domainAlan, + nextDomainType: domainAlan, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + beacon := *TestNetwork.Beacon + beacon.SlotsPerEpoch = test.slotsPerEpoch + netCfg := Network{ + Beacon: &beacon, + SSV: &SSV{ + DomainType: test.domainType, + NextDomainType: test.nextDomainType, + Forks: SSVForks{Boole: test.boole}, + }, + } + + warnings, err := netCfg.Validate() + if test.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Len(t, warnings, test.expectedWarnings) + }) + } +} + func beaconAtEpoch(epoch phase0.Epoch) *Beacon { beacon := *TestNetwork.Beacon slotsSinceGenesis := uint64(epoch) * beacon.SlotsPerEpoch From c0411b783319a1c29227b8480fed818576d576b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Thu, 30 Jul 2026 14:31:51 +0200 Subject: [PATCH 03/10] protocol/v2/qbft/spectest: derive the alan error-code remap bound The remap's magic upper bound (79) was disconnected from the real v1.2.2 maximum (72). Derive it from TimeoutInstanceErrorCode - the last pre-Boole-era spec error code - plus the documented +2 shift, so a spec bump that adds codes can't silently widen the remap. Item 7 of #2968. --- protocol/v2/qbft/spectest/error_code_map_alan.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/protocol/v2/qbft/spectest/error_code_map_alan.go b/protocol/v2/qbft/spectest/error_code_map_alan.go index b7f335d4af..36a0bb3ab3 100644 --- a/protocol/v2/qbft/spectest/error_code_map_alan.go +++ b/protocol/v2/qbft/spectest/error_code_map_alan.go @@ -4,6 +4,13 @@ package qbft import spectypes "github.com/ssvlabs/ssv-spec/types" +// maxShiftableLegacyErrorCode is the highest v1.2.2 error code eligible for the -2 shift below. +// spectypes.TimeoutInstanceErrorCode is the last error code that existed in the spec before the +// Boole-era additions (AggComm*/Committee* codes), which have no v1.2.2 counterpart and thus were +// never subject to the shift. Its v1.2.2 value is its current (v1.2.3+) value plus the 2-code +// offset described below: 70 + 2 = 72. +const maxShiftableLegacyErrorCode = int(spectypes.TimeoutInstanceErrorCode) + 2 + func adjustExpectedErrorCode(code int) int { // Alan fixtures use v1.2.2 error-code numbering. v1.2.3 removed two enum // members after code 9, so most legacy codes shift down by 2. @@ -14,7 +21,7 @@ func adjustExpectedErrorCode(code int) int { return spectypes.ValidatorExitNoConsensusPhaseErrorCode } - if code >= 12 && code <= 79 { + if code >= 12 && code <= maxShiftableLegacyErrorCode { return code - 2 } From 8e85bcce25c5a9205c566c568ef494aecad970cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Thu, 6 Aug 2026 10:33:36 +0200 Subject: [PATCH 04/10] operator/validator: fix copy-pasted straggler wording in SCC gating doc --- operator/validator/controller.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/operator/validator/controller.go b/operator/validator/controller.go index 10d42b9869..cfc1e523f0 100644 --- a/operator/validator/controller.go +++ b/operator/validator/controller.go @@ -1251,8 +1251,8 @@ func SetupRunners( // cross-population asymmetry (some operators still running the legacy runner while others // have moved to the committee runner) is bounded to the Boole subsequent window // (SlotsPerEpoch + booleSubsequentWindowLateSlots; 34 slots on mainnet — see InBooleTransitionWindow) - // and only affects already-decided pre-fork aggregator stragglers — no correctness impact. - // These legacy runners retire wholesale together with the Alan topics. + // and only affects already-decided pre-fork sync committee contribution stragglers — no + // correctness impact. These legacy runners retire wholesale together with the Alan topics. if !options.NetworkConfig.BooleFork() { syncCommitteeContributionValueChecker := ssv.NewSyncCommitteeContributionChecker(options.NetworkConfig.Beacon, share.ValidatorPubKey, share.ValidatorIndex) runners[role], err = runner.NewSyncCommitteeAggregatorRunner(runner.SyncCommitteeAggregatorRunnerOptions{ From 0a6d483f44d8d5d970b938e30023425fedfa78c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Thu, 6 Aug 2026 10:33:36 +0200 Subject: [PATCH 05/10] networkconfig: cover genesis-fork and exact max-epoch validation boundaries --- networkconfig/network_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/networkconfig/network_test.go b/networkconfig/network_test.go index aa6068d14c..c8ba37479d 100644 --- a/networkconfig/network_test.go +++ b/networkconfig/network_test.go @@ -246,6 +246,20 @@ func TestNetworkValidate(t *testing.T) { domainType: domainAlan, nextDomainType: domainBoole, }, + { + name: "clean_genesis_fork", + boole: 0, + slotsPerEpoch: 32, + domainType: domainAlan, + nextDomainType: domainBoole, + }, + { + name: "clean_at_max_epoch_boundary", + boole: phase0.Epoch(math.MaxUint64 / 32), + slotsPerEpoch: 32, + domainType: domainAlan, + nextDomainType: domainBoole, + }, { name: "clean_unscheduled", boole: phase0.Epoch(math.MaxUint64), From 4b8bf87bb56e2e113788a5be6b1d2085053180e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Thu, 6 Aug 2026 12:16:30 +0200 Subject: [PATCH 06/10] networkconfig: validate slots-per-epoch unconditionally, not just for scheduled forks --- networkconfig/network.go | 14 +++++++------- networkconfig/network_test.go | 8 ++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/networkconfig/network.go b/networkconfig/network.go index 38ba6dec5f..6cb12cab49 100644 --- a/networkconfig/network.go +++ b/networkconfig/network.go @@ -87,14 +87,14 @@ func (n Network) BooleForkAtSlot(slot phase0.Slot) bool { // It stays logger-free by design: the caller decides how to surface warnings (e.g. logger.Warn) // and whether/how to abort on error. func (n Network) Validate() (warnings []string, err error) { - if n.BooleForkScheduled() { - // Guard the division below the way inBooleSubsequentWindowWithSlots does — but as a hard - // error: a zero SlotsPerEpoch is exactly the malformed-config class Validate exists to - // catch, and would otherwise panic here. - if n.SlotsPerEpoch == 0 { - return nil, fmt.Errorf("slots per epoch must be positive when a boole fork is scheduled") - } + // A zero SlotsPerEpoch is a malformed config regardless of fork scheduling: slot/epoch + // conversions divide by it unconditionally (e.g. Beacon.EstimatedEpochAtSlot), so it would + // panic on the first duty long before any fork logic runs. + if n.SlotsPerEpoch == 0 { + return nil, fmt.Errorf("slots per epoch must be positive") + } + if n.BooleForkScheduled() { // Mirrors the overflow guard in inBooleSubsequentWindowWithSlots: FirstSlotAtEpoch(Boole) // would overflow if the fork epoch is beyond the representable slot range. maxEpoch := phase0.Epoch(math.MaxUint64 / n.SlotsPerEpoch) diff --git a/networkconfig/network_test.go b/networkconfig/network_test.go index c8ba37479d..804997e604 100644 --- a/networkconfig/network_test.go +++ b/networkconfig/network_test.go @@ -223,6 +223,14 @@ func TestNetworkValidate(t *testing.T) { nextDomainType: domainBoole, expectErr: true, }, + { + name: "hard_error_zero_slots_per_epoch_unscheduled", + boole: phase0.Epoch(math.MaxUint64), + slotsPerEpoch: 0, + domainType: domainAlan, + nextDomainType: domainAlan, + expectErr: true, + }, { name: "hard_error_overflow", boole: phase0.Epoch(math.MaxUint64/32) + 1, From 0fe941fea8c4a99c788bc3ed00addc222ec3bab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Thu, 6 Aug 2026 13:22:29 +0200 Subject: [PATCH 07/10] networkconfig: reject an unchanged domain type while the fork is still ahead A scheduled Boole fork whose NextDomainType equals DomainType is the signature of a config that forgot to set NextDomainType (the YAML unmarshal defaults it), so fail startup instead of warning while the fork is still ahead. Once the fork is active, equality is legitimate steady state - a post-fork config may collapse both fields to the post-fork domain - so it passes validation. --- networkconfig/network.go | 14 +++++++++----- networkconfig/network_test.go | 24 ++++++++++++++++++------ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/networkconfig/network.go b/networkconfig/network.go index 6cb12cab49..fa202cef2e 100644 --- a/networkconfig/network.go +++ b/networkconfig/network.go @@ -103,13 +103,17 @@ func (n Network) Validate() (warnings []string, err error) { } // unmarshalFromConfig defaults NextDomainType to DomainType when the field is absent, so - // equality here is exactly the signature of a scheduled fork that forgot to set - // NextDomainType: it would "activate" with zero observable domain change. - if n.NextDomainType == n.DomainType { - warnings = append(warnings, fmt.Sprintf( + // equality while the fork is still ahead is exactly the signature of a scheduled fork that + // forgot to set NextDomainType: it would "activate" with zero observable domain change, and + // a restart is guaranteed before activation (see BooleForkScheduled), so refusing to start + // is the last safe moment to catch it. Once the fork is active, equality is legitimate + // steady state — a config written post-fork may set DomainType to the post-fork domain and + // omit NextDomainType. + if n.NextDomainType == n.DomainType && !n.BooleFork() { + return nil, fmt.Errorf( "boole fork is scheduled at epoch %d but NextDomainType equals DomainType: the fork would activate with no observable domain change", n.SSV.Forks.Boole, - )) + ) } } diff --git a/networkconfig/network_test.go b/networkconfig/network_test.go index 804997e604..01b5cfe2bc 100644 --- a/networkconfig/network_test.go +++ b/networkconfig/network_test.go @@ -209,6 +209,7 @@ func TestNetworkValidate(t *testing.T) { tests := []struct { name string boole phase0.Epoch + currentEpoch phase0.Epoch // wall-clock epoch the config is validated at slotsPerEpoch uint64 domainType spectypes.DomainType nextDomainType spectypes.DomainType @@ -240,12 +241,21 @@ func TestNetworkValidate(t *testing.T) { expectErr: true, }, { - name: "warning_next_domain_equals_domain", - boole: 10, - slotsPerEpoch: 32, - domainType: domainAlan, - nextDomainType: domainAlan, - expectedWarnings: 1, + name: "hard_error_next_domain_equals_domain_before_fork", + boole: 10, + currentEpoch: 5, + slotsPerEpoch: 32, + domainType: domainAlan, + nextDomainType: domainAlan, + expectErr: true, + }, + { + name: "clean_next_domain_equals_domain_after_fork", + boole: 10, + currentEpoch: 20, + slotsPerEpoch: 32, + domainType: domainBoole, + nextDomainType: domainBoole, }, { name: "clean_scheduled", @@ -281,6 +291,8 @@ func TestNetworkValidate(t *testing.T) { t.Run(test.name, func(t *testing.T) { beacon := *TestNetwork.Beacon beacon.SlotsPerEpoch = test.slotsPerEpoch + slotsSinceGenesis := uint64(test.currentEpoch) * test.slotsPerEpoch + beacon.GenesisTime = time.Now().Add(-time.Duration(slotsSinceGenesis) * beacon.SlotDuration) netCfg := Network{ Beacon: &beacon, SSV: &SSV{ From 49c492755feb667d7d2bfa2e4c1019c93169ba9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Thu, 6 Aug 2026 17:50:52 +0200 Subject: [PATCH 08/10] networkconfig: don't panic validating an unchanged domain pre-genesis EstimatedCurrentSlot panics while the wall clock is still before GenesisTime, so the unchanged-domain check would panic instead of returning the intended error on a not-yet-genesis network. Before genesis a scheduled fork is by definition not yet active, so guard the wall-clock read and keep reporting the misconfiguration as an error. --- networkconfig/network.go | 7 ++++++- networkconfig/network_test.go | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/networkconfig/network.go b/networkconfig/network.go index fa202cef2e..72ec0bd753 100644 --- a/networkconfig/network.go +++ b/networkconfig/network.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "math" + "time" "github.com/attestantio/go-eth2-client/spec/phase0" spectypes "github.com/ssvlabs/ssv-spec/types" @@ -109,7 +110,11 @@ func (n Network) Validate() (warnings []string, err error) { // is the last safe moment to catch it. Once the fork is active, equality is legitimate // steady state — a config written post-fork may set DomainType to the post-fork domain and // omit NextDomainType. - if n.NextDomainType == n.DomainType && !n.BooleFork() { + // The wall-clock read must be guarded: EstimatedCurrentSlot panics while the clock is + // still before GenesisTime, and before genesis a scheduled fork is by definition not + // yet active. + booleForkActive := !time.Now().Before(n.GenesisTime) && n.BooleFork() + if n.NextDomainType == n.DomainType && !booleForkActive { return nil, fmt.Errorf( "boole fork is scheduled at epoch %d but NextDomainType equals DomainType: the fork would activate with no observable domain change", n.SSV.Forks.Boole, diff --git a/networkconfig/network_test.go b/networkconfig/network_test.go index 01b5cfe2bc..0439ae26ea 100644 --- a/networkconfig/network_test.go +++ b/networkconfig/network_test.go @@ -210,6 +210,7 @@ func TestNetworkValidate(t *testing.T) { name string boole phase0.Epoch currentEpoch phase0.Epoch // wall-clock epoch the config is validated at + preGenesis bool // put GenesisTime in the future (overrides currentEpoch) slotsPerEpoch uint64 domainType spectypes.DomainType nextDomainType spectypes.DomainType @@ -249,6 +250,18 @@ func TestNetworkValidate(t *testing.T) { nextDomainType: domainAlan, expectErr: true, }, + { + // The unchanged-domain check must not panic on EstimatedCurrentSlot when the + // clock is still before genesis; pre-genesis the fork is not yet active, so the + // misconfiguration is still reported as an error. + name: "hard_error_next_domain_equals_domain_pre_genesis", + boole: 10, + preGenesis: true, + slotsPerEpoch: 32, + domainType: domainAlan, + nextDomainType: domainAlan, + expectErr: true, + }, { name: "clean_next_domain_equals_domain_after_fork", boole: 10, @@ -293,6 +306,9 @@ func TestNetworkValidate(t *testing.T) { beacon.SlotsPerEpoch = test.slotsPerEpoch slotsSinceGenesis := uint64(test.currentEpoch) * test.slotsPerEpoch beacon.GenesisTime = time.Now().Add(-time.Duration(slotsSinceGenesis) * beacon.SlotDuration) + if test.preGenesis { + beacon.GenesisTime = time.Now().Add(time.Hour) + } netCfg := Network{ Beacon: &beacon, SSV: &SSV{ From 7c32997aaa66e9b278b881e801f66a49090da907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Thu, 6 Aug 2026 17:51:35 +0200 Subject: [PATCH 09/10] networkconfig: drop the vestigial warnings channel from Validate Once the unchanged-domain case became a hard error, no code path ever populated the warnings return: the node.go warn loop never ran and the test's expectedWarnings field only ever asserted emptiness. Collapse the signature to a plain error so it no longer advertises a code path that can't happen. --- cli/operator/node.go | 6 +----- networkconfig/network.go | 16 +++++++--------- networkconfig/network_test.go | 20 +++++++++----------- 3 files changed, 17 insertions(+), 25 deletions(-) diff --git a/cli/operator/node.go b/cli/operator/node.go index 590c14f95b..18d21520ac 100644 --- a/cli/operator/node.go +++ b/cli/operator/node.go @@ -105,13 +105,9 @@ func buildNode(ctx context.Context, cfg *config, logger *zap.Logger) (*node, err SSV: ssvNetworkConfig, Beacon: consensusClient.BeaconConfig(), } - warnings, err := networkConfig.Validate() - if err != nil { + if err := networkConfig.Validate(); err != nil { return nil, fmt.Errorf("invalid network config: %w", err) } - for _, warning := range warnings { - logger.Warn(warning) - } var executionAddrList []string for _, addr := range strings.Split(cfg.ExecutionClient.Addr, ";") { diff --git a/networkconfig/network.go b/networkconfig/network.go index 72ec0bd753..d6a8556621 100644 --- a/networkconfig/network.go +++ b/networkconfig/network.go @@ -83,16 +83,14 @@ func (n Network) BooleForkAtSlot(slot phase0.Slot) bool { } // Validate checks the assembled network configuration for internal inconsistencies that fork -// activation logic can't catch on its own. It returns non-fatal warnings for configurations that -// are almost certainly a mistake, and an error for configurations that would panic or misbehave. -// It stays logger-free by design: the caller decides how to surface warnings (e.g. logger.Warn) -// and whether/how to abort on error. -func (n Network) Validate() (warnings []string, err error) { +// activation logic can't catch on its own, returning an error for configurations that would +// panic or misbehave. It stays logger-free by design: the caller decides whether/how to abort. +func (n Network) Validate() error { // A zero SlotsPerEpoch is a malformed config regardless of fork scheduling: slot/epoch // conversions divide by it unconditionally (e.g. Beacon.EstimatedEpochAtSlot), so it would // panic on the first duty long before any fork logic runs. if n.SlotsPerEpoch == 0 { - return nil, fmt.Errorf("slots per epoch must be positive") + return fmt.Errorf("slots per epoch must be positive") } if n.BooleForkScheduled() { @@ -100,7 +98,7 @@ func (n Network) Validate() (warnings []string, err error) { // would overflow if the fork epoch is beyond the representable slot range. maxEpoch := phase0.Epoch(math.MaxUint64 / n.SlotsPerEpoch) if n.SSV.Forks.Boole > maxEpoch { - return nil, fmt.Errorf("boole fork epoch %d overflows slot conversion (max supported epoch %d)", n.SSV.Forks.Boole, maxEpoch) + return fmt.Errorf("boole fork epoch %d overflows slot conversion (max supported epoch %d)", n.SSV.Forks.Boole, maxEpoch) } // unmarshalFromConfig defaults NextDomainType to DomainType when the field is absent, so @@ -115,14 +113,14 @@ func (n Network) Validate() (warnings []string, err error) { // yet active. booleForkActive := !time.Now().Before(n.GenesisTime) && n.BooleFork() if n.NextDomainType == n.DomainType && !booleForkActive { - return nil, fmt.Errorf( + return fmt.Errorf( "boole fork is scheduled at epoch %d but NextDomainType equals DomainType: the fork would activate with no observable domain change", n.SSV.Forks.Boole, ) } } - return warnings, nil + return nil } // InBooleTransitionWindow checks if the slot is in the Boole transition window, diff --git a/networkconfig/network_test.go b/networkconfig/network_test.go index 0439ae26ea..e26bc1280a 100644 --- a/networkconfig/network_test.go +++ b/networkconfig/network_test.go @@ -207,15 +207,14 @@ func TestNetworkValidate(t *testing.T) { domainBoole := spectypes.DomainType{0x05, 0x06, 0x07, 0x08} tests := []struct { - name string - boole phase0.Epoch - currentEpoch phase0.Epoch // wall-clock epoch the config is validated at - preGenesis bool // put GenesisTime in the future (overrides currentEpoch) - slotsPerEpoch uint64 - domainType spectypes.DomainType - nextDomainType spectypes.DomainType - expectErr bool - expectedWarnings int + name string + boole phase0.Epoch + currentEpoch phase0.Epoch // wall-clock epoch the config is validated at + preGenesis bool // put GenesisTime in the future (overrides currentEpoch) + slotsPerEpoch uint64 + domainType spectypes.DomainType + nextDomainType spectypes.DomainType + expectErr bool }{ { name: "hard_error_zero_slots_per_epoch", @@ -318,13 +317,12 @@ func TestNetworkValidate(t *testing.T) { }, } - warnings, err := netCfg.Validate() + err := netCfg.Validate() if test.expectErr { require.Error(t, err) return } require.NoError(t, err) - require.Len(t, warnings, test.expectedWarnings) }) } } From 401a1cc7659e25045ac342d31fe33f9c594477f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Thu, 6 Aug 2026 17:52:09 +0200 Subject: [PATCH 10/10] networkconfig: share the fork-epoch slot-overflow guard Validate and inBooleSubsequentWindowWithSlots duplicated the MaxUint64/SlotsPerEpoch bound verbatim; a cross-reference comment kept them associated but nothing kept them in lockstep. Extract maxEpochConvertibleToSlot so both sites derive the bound from one place. --- networkconfig/network.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/networkconfig/network.go b/networkconfig/network.go index d6a8556621..d5fb9ba2ac 100644 --- a/networkconfig/network.go +++ b/networkconfig/network.go @@ -94,10 +94,7 @@ func (n Network) Validate() error { } if n.BooleForkScheduled() { - // Mirrors the overflow guard in inBooleSubsequentWindowWithSlots: FirstSlotAtEpoch(Boole) - // would overflow if the fork epoch is beyond the representable slot range. - maxEpoch := phase0.Epoch(math.MaxUint64 / n.SlotsPerEpoch) - if n.SSV.Forks.Boole > maxEpoch { + if maxEpoch := n.maxEpochConvertibleToSlot(); n.SSV.Forks.Boole > maxEpoch { return fmt.Errorf("boole fork epoch %d overflows slot conversion (max supported epoch %d)", n.SSV.Forks.Boole, maxEpoch) } @@ -123,6 +120,13 @@ func (n Network) Validate() error { return nil } +// maxEpochConvertibleToSlot returns the highest epoch whose first slot still fits the slot +// range; FirstSlotAtEpoch would overflow beyond it. Callers must rule out a zero SlotsPerEpoch +// first, or the division panics. +func (n Network) maxEpochConvertibleToSlot() phase0.Epoch { + return phase0.Epoch(math.MaxUint64 / n.SlotsPerEpoch) +} + // InBooleTransitionWindow checks if the slot is in the Boole transition window, // i.e., in `PRIOR_WINDOW` or `SUBSEQUENT_WINDOW` according to https://github.com/ssvlabs/SIPs/pull/43. func (n Network) InBooleTransitionWindow(slot phase0.Slot) bool { @@ -162,8 +166,7 @@ func (n Network) inBooleSubsequentWindowWithSlots(slot phase0.Slot, windowSlots // Avoid FirstSlotAtEpoch overflow when Boole is beyond the representable epoch range; // without this guard the multiplication would wrap and could treat small slots as in-window. - maxEpoch := phase0.Epoch(math.MaxUint64 / n.SlotsPerEpoch) - if n.SSV.Forks.Boole > maxEpoch { + if n.SSV.Forks.Boole > n.maxEpochConvertibleToSlot() { return false }