From 83200f6a0306eda3dccdf46825a5722474fdf33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 11 Aug 2026 16:19:32 +0200 Subject: [PATCH 01/11] message/validation: make partial-signature size cap fork-aware (#2978 item 3) Pre-fork, enforce the pre-boole envelope (1512 msgs, ~229 KB) instead of the post-fork AggregatorCommittee worst case (5048 msgs, ~763 KB), keeping the pre-fork decode DoS surface at its pre-boole size. The switch is wall-clock based (slot is unknown before decode) and flips one epoch early to avoid rejecting boundary messages. Drift-guarded in const_test.go against the spec v1.2.2 worst case. --- message/validation/const.go | 11 +++++++++++ message/validation/const_test.go | 11 +++++++++++ message/validation/partial_validation.go | 18 ++++++++++++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/message/validation/const.go b/message/validation/const.go index 3c2eb29cc8..06240653d4 100644 --- a/message/validation/const.go +++ b/message/validation/const.go @@ -51,6 +51,17 @@ const ( partialSigMsgTypeSize = 8 // uint64 maxPartialSignatureMsgsSize = partialSigMsgTypeSize + slotSize + maxPartialSignatureMessages*partialSignatureMsgSize maxEncodedPartialSignatureSize = maxPartialSignatureMsgsSize + maxPartialSignatureMsgsSize/encodingOverheadDivisor + 4 + + // preForkMaxPartialSignatureMessages is the pre-boole worst case (RoleCommittee, + // min(2*V, V+SYNC_COMMITTEE_SIZE) with the spec's V=1000 bound), matching pre-boole + // ssv-spec v1.2.2 maxmsgsize.maxSizePartialSignatureMessages (1512 messages, 217748 + // bytes). Deliberately above the 1000 enforced before the boole convergence, which + // sat slightly below the spec's structural bound. The pinned (post-fork) spec no + // longer publishes this constant, so const_test.go guards it against the hardcoded + // v1.2.2 value instead. + preForkMaxPartialSignatureMessages = 1512 + preForkMaxPartialSignatureMsgsSize = partialSigMsgTypeSize + slotSize + preForkMaxPartialSignatureMessages*partialSignatureMsgSize + preForkMaxEncodedPartialSignatureSize = preForkMaxPartialSignatureMsgsSize + preForkMaxPartialSignatureMsgsSize/encodingOverheadDivisor + 4 ) const ( diff --git a/message/validation/const_test.go b/message/validation/const_test.go index cac2f07dfa..164e752493 100644 --- a/message/validation/const_test.go +++ b/message/validation/const_test.go @@ -7,6 +7,12 @@ import ( "github.com/stretchr/testify/require" ) +// specV122MaxSizePartialSignatureMessages mirrors pre-boole ssv-spec v1.2.2's +// maxmsgsize.maxSizePartialSignatureMessages (1512 messages). It is unexported there and +// only one spec version can be pinned, so the value is hardcoded here to guard the +// pre-fork cap. +const specV122MaxSizePartialSignatureMessages = 217748 + // TestSizeCapsCoverSpecWorstCase guards against our hand-computed size caps // drifting below the pinned ssv-spec's worst-case message sizes. If this // fails after a spec bump, re-derive the corresponding const.go values. @@ -14,4 +20,9 @@ func TestSizeCapsCoverSpecWorstCase(t *testing.T) { require.GreaterOrEqual(t, maxEncodedPartialSignatureSize, maxmsgsize.MaxSizeSSVMessageFromPartialSignatureMessages) require.GreaterOrEqual(t, maxEncodedConsensusMsgSize, maxmsgsize.MaxSizeSSVMessageFromQBFTMessage) require.GreaterOrEqual(t, MaxEncodedMsgSize, maxmsgsize.MaxSizeSignedSSVMessageFromQBFTWith2Justification) + + // The pre-fork cap must cover the pre-boole spec's structural worst case but stay + // below the post-fork cap (otherwise the fork-aware switch would be pointless). + require.GreaterOrEqual(t, preForkMaxEncodedPartialSignatureSize, specV122MaxSizePartialSignatureMessages) + require.Less(t, preForkMaxEncodedPartialSignatureSize, maxEncodedPartialSignatureSize) } diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index 649b031e07..0d6d8b9f69 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -30,10 +30,10 @@ func (mv *messageValidator) validatePartialSignatureMessage( ) { ssvMessage := signedSSVMessage.SSVMessage - if len(ssvMessage.Data) > maxEncodedPartialSignatureSize { + if maxSize := mv.currentMaxEncodedPartialSignatureSize(); len(ssvMessage.Data) > maxSize { e := ErrSSVDataTooBig e.got = len(ssvMessage.Data) - e.want = maxEncodedPartialSignatureSize + e.want = maxSize return nil, e } @@ -79,6 +79,20 @@ func (mv *messageValidator) validatePartialSignatureMessage( return partialSignatureMessages, nil } +// currentMaxEncodedPartialSignatureSize returns the acceptance cap for encoded +// partial-signature message data. The post-fork (boole AggregatorCommittee) worst case is +// ~5x the pre-fork one, so pre-fork the smaller cap is enforced to keep the decode DoS +// surface at its pre-boole size. The cap is enforced before decoding, when the message's +// own slot is not yet known, so unlike the other fork gates in this package the switch is +// wall-clock based — and flips one epoch before boole activation so that messages for +// post-fork slots arriving early (clock skew) are never rejected against the smaller cap. +func (mv *messageValidator) currentMaxEncodedPartialSignatureSize() int { + if mv.netCfg.BooleForkAtEpoch(mv.netCfg.EstimatedCurrentEpoch() + 1) { + return maxEncodedPartialSignatureSize + } + return preForkMaxEncodedPartialSignatureSize +} + func (mv *messageValidator) validatePartialSignatureMessageSemantics( signedSSVMessage *spectypes.SignedSSVMessage, partialSignatureMessages *spectypes.PartialSignatureMessages, From 98986edf683f8bde73840d5f81db1f0dab9a7eea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 11 Aug 2026 16:19:32 +0200 Subject: [PATCH 02/11] protocol/v2: checked runner type assertions in createRunner (#2978 item 4) A CreateRunnerFn returning a mismatched runner type now surfaces as a descriptive error instead of a bare interface-conversion panic. --- protocol/v2/ssv/validator/committee.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/protocol/v2/ssv/validator/committee.go b/protocol/v2/ssv/validator/committee.go index e735cdaa70..d73b7eccb0 100644 --- a/protocol/v2/ssv/validator/committee.go +++ b/protocol/v2/ssv/validator/committee.go @@ -563,9 +563,17 @@ func (c *Committee) createRunner( switch duty := duty.(type) { case *spectypes.CommitteeDuty: - c.Runners[duty.DutySlot()] = r.(*runner.CommitteeRunner) + cr, ok := r.(*runner.CommitteeRunner) + if !ok { + return nil, fmt.Errorf("BUG: runner created for committee duty has type %T, expected *runner.CommitteeRunner", r) + } + c.Runners[duty.DutySlot()] = cr case *spectypes.AggregatorCommitteeDuty: - c.AggregatorRunners[duty.DutySlot()] = r.(*runner.AggregatorCommitteeRunner) + ar, ok := r.(*runner.AggregatorCommitteeRunner) + if !ok { + return nil, fmt.Errorf("BUG: runner created for aggregator committee duty has type %T, expected *runner.AggregatorCommitteeRunner", r) + } + c.AggregatorRunners[duty.DutySlot()] = ar default: c.logger.Panic("BUG: attempt to create committee runner with non-committee duty type", zap.String("type", fmt.Sprintf("%T", duty))) From 6efc02598fe177dd69b6731ee719c16b4961f992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 11 Aug 2026 16:19:32 +0200 Subject: [PATCH 03/11] observability: lockstep test for the two runner-role string mappers (#2978 item 7) message.RunnerRoleToString and ssvtypes.RunnerRoleToString/utils.FormatRunnerRole must produce the same strings; the contract lived only in doc comments. --- observability/utils/format_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/observability/utils/format_test.go b/observability/utils/format_test.go index 827570c013..7a738f794d 100644 --- a/observability/utils/format_test.go +++ b/observability/utils/format_test.go @@ -6,6 +6,7 @@ import ( spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" + "github.com/ssvlabs/ssv/protocol/v2/message" ssvtypes "github.com/ssvlabs/ssv/protocol/v2/types" ) @@ -78,3 +79,29 @@ func TestFormatRunnerRole(t *testing.T) { require.NotEqual(t, FormatRunnerRole(ssvtypes.RoleAggregator), FormatRunnerRole(ssvtypes.RoleSyncCommitteeContribution)) }) } + +// TestRunnerRoleStringMappersLockstep guards the contract documented on +// ssvtypes.RunnerRoleToString and message.RunnerRoleToString: the two mappers are +// independent (one reaches the strings via the spec's String() plus a deprecated-role +// shim, the other via its own switch) and must produce the same string for every runner +// role that is valid in any fork. A role added or deprecated in one must be reflected in +// the other — this test is what fails when they drift. +func TestRunnerRoleStringMappersLockstep(t *testing.T) { + t.Parallel() + + // The full role union across forks, mirroring messageValidator.validRoleUnion. + roles := []spectypes.RunnerRole{ + spectypes.RoleCommittee, + spectypes.RoleAggregatorCommittee, + spectypes.RoleProposer, + spectypes.RoleValidatorRegistration, + spectypes.RoleVoluntaryExit, + ssvtypes.RoleAggregator, + ssvtypes.RoleSyncCommitteeContribution, + } + + for _, role := range roles { + require.Equal(t, message.RunnerRoleToString(role), FormatRunnerRole(role), + "role %d: message.RunnerRoleToString and utils.FormatRunnerRole disagree", role) + } +} From 947b9547ce0b3f6fa126c49619dd41066eabb2e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 11 Aug 2026 16:35:16 +0200 Subject: [PATCH 04/11] address deep-review findings on #2989 - pin the fork gate of the partial-signature cap with a unit test (unscheduled / two-epochs-out / one-epoch-early flip / active) - extend the role-mapper lockstep test with a sweep over spec-known roles so a role added to only one mapper fails the test - guard createRunner against a nil runner returned without error - comment accuracy: the cap bounds the inner PartialSignatureMessages decode (outer decode is bounded by MaxEncodedMsgSize); note why the two drift guards compare against different spec constants; return r, nil explicitly --- message/validation/const_test.go | 4 ++ message/validation/partial_validation.go | 15 +++-- message/validation/partial_validation_test.go | 62 +++++++++++++++++++ observability/utils/format_test.go | 15 +++++ protocol/v2/ssv/validator/committee.go | 5 +- 5 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 message/validation/partial_validation_test.go diff --git a/message/validation/const_test.go b/message/validation/const_test.go index 164e752493..4fe6201336 100644 --- a/message/validation/const_test.go +++ b/message/validation/const_test.go @@ -17,6 +17,10 @@ const specV122MaxSizePartialSignatureMessages = 217748 // drifting below the pinned ssv-spec's worst-case message sizes. If this // fails after a spec bump, re-derive the corresponding const.go values. func TestSizeCapsCoverSpecWorstCase(t *testing.T) { + // The post-fork cap is compared against the spec's full-SSVMessage-envelope constant, + // which is over-conservative: the cap applies to SSVMessage.Data, the inner encoding. + // The pre-fork guard below compares against the inner v1.2.2 constant instead — the + // only partial-signature size constant that spec version published. require.GreaterOrEqual(t, maxEncodedPartialSignatureSize, maxmsgsize.MaxSizeSSVMessageFromPartialSignatureMessages) require.GreaterOrEqual(t, maxEncodedConsensusMsgSize, maxmsgsize.MaxSizeSSVMessageFromQBFTMessage) require.GreaterOrEqual(t, MaxEncodedMsgSize, maxmsgsize.MaxSizeSignedSSVMessageFromQBFTWith2Justification) diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index 0d6d8b9f69..1574cd99c6 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -80,12 +80,15 @@ func (mv *messageValidator) validatePartialSignatureMessage( } // currentMaxEncodedPartialSignatureSize returns the acceptance cap for encoded -// partial-signature message data. The post-fork (boole AggregatorCommittee) worst case is -// ~5x the pre-fork one, so pre-fork the smaller cap is enforced to keep the decode DoS -// surface at its pre-boole size. The cap is enforced before decoding, when the message's -// own slot is not yet known, so unlike the other fork gates in this package the switch is -// wall-clock based — and flips one epoch before boole activation so that messages for -// post-fork slots arriving early (clock skew) are never rejected against the smaller cap. +// partial-signature message data (SSVMessage.Data). The post-fork (boole +// AggregatorCommittee) worst case is ~3.3x the pre-fork one, so pre-fork the smaller cap +// is enforced, bounding the inner PartialSignatureMessages decode at the pre-fork worst +// case (~229 KB vs ~763 KB; the outer SignedSSVMessage decode that already happened is +// bounded separately by MaxEncodedMsgSize). The cap is enforced before decoding, when the +// message's own slot is not yet known, so unlike the other fork gates in this package the +// switch is wall-clock based — and flips one epoch before boole activation (mirroring +// SIP-43's one-epoch prior window) so that messages for post-fork slots arriving early +// (clock skew) are never rejected against the smaller cap. func (mv *messageValidator) currentMaxEncodedPartialSignatureSize() int { if mv.netCfg.BooleForkAtEpoch(mv.netCfg.EstimatedCurrentEpoch() + 1) { return maxEncodedPartialSignatureSize diff --git a/message/validation/partial_validation_test.go b/message/validation/partial_validation_test.go new file mode 100644 index 0000000000..fb192489c7 --- /dev/null +++ b/message/validation/partial_validation_test.go @@ -0,0 +1,62 @@ +package validation + +import ( + "math" + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/ssvlabs/ssv/networkconfig" +) + +// TestCurrentMaxEncodedPartialSignatureSize pins the fork gate of the pre-decode +// partial-signature size cap: the pre-fork cap applies while boole is unscheduled or more +// than one epoch away, and the post-fork cap applies from one epoch before activation +// (the early flip that protects boundary messages) onward. +func TestCurrentMaxEncodedPartialSignatureSize(t *testing.T) { + t.Parallel() + + cfgWithBoole := func(booleEpoch phase0.Epoch) *networkconfig.Network { + ssv := *networkconfig.TestNetwork.SSV + ssv.Forks = networkconfig.SSVForks{Boole: booleEpoch} + return &networkconfig.Network{Beacon: networkconfig.TestNetwork.Beacon, SSV: &ssv} + } + currentEpoch := networkconfig.TestNetwork.EstimatedCurrentEpoch() + + testCases := []struct { + name string + boole phase0.Epoch + want int + }{ + { + name: "unscheduled fork keeps the pre-fork cap", + boole: math.MaxUint64, + want: preForkMaxEncodedPartialSignatureSize, + }, + { + name: "fork two epochs away keeps the pre-fork cap", + boole: currentEpoch + 2, + want: preForkMaxEncodedPartialSignatureSize, + }, + { + name: "cap flips one epoch before activation", + boole: currentEpoch + 1, + want: maxEncodedPartialSignatureSize, + }, + { + name: "active fork uses the post-fork cap", + boole: 0, + want: maxEncodedPartialSignatureSize, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + mv := &messageValidator{netCfg: cfgWithBoole(tc.boole)} + require.Equal(t, tc.want, mv.currentMaxEncodedPartialSignatureSize()) + }) + } +} diff --git a/observability/utils/format_test.go b/observability/utils/format_test.go index 7a738f794d..e14db44d1a 100644 --- a/observability/utils/format_test.go +++ b/observability/utils/format_test.go @@ -104,4 +104,19 @@ func TestRunnerRoleStringMappersLockstep(t *testing.T) { require.Equal(t, message.RunnerRoleToString(role), FormatRunnerRole(role), "role %d: message.RunnerRoleToString and utils.FormatRunnerRole disagree", role) } + + // Sweep beyond the explicit list so a role added to the spec — which FormatRunnerRole + // picks up automatically via (RunnerRole).String() but message.RunnerRoleToString's + // hand-written switch would miss — fails here instead of drifting silently. Roles the + // spec does not know return "UNDEFINED" and are skipped: divergence on genuinely + // unknown values is intentional (the deprecated Alan roles also stringify to + // "UNDEFINED" in the spec, but they are covered by the explicit list above). + for i := 0; i <= 15; i++ { + role := spectypes.RunnerRole(i) + if role.String() == "UNDEFINED" { + continue + } + require.Equal(t, message.RunnerRoleToString(role), FormatRunnerRole(role), + "role %d is known to the spec but the two mappers disagree", role) + } } diff --git a/protocol/v2/ssv/validator/committee.go b/protocol/v2/ssv/validator/committee.go index d73b7eccb0..7a402b3a46 100644 --- a/protocol/v2/ssv/validator/committee.go +++ b/protocol/v2/ssv/validator/committee.go @@ -550,6 +550,9 @@ func (c *Committee) createRunner( if err != nil { return nil, fmt.Errorf("create committee runner: %w", err) } + if r == nil { + return nil, fmt.Errorf("BUG: CreateRunnerFn returned nil runner without error") + } // Wire the QBFT round-timer factory, bound to a msg ID carrying this duty's role so timeout // events are routed to the matching (committee vs aggregator-committee) slot queue. @@ -579,7 +582,7 @@ func (c *Committee) createRunner( zap.String("type", fmt.Sprintf("%T", duty))) } - return r, err + return r, nil } func (c *Committee) extractValidatorDuties(duty spectypes.Duty) []*spectypes.ValidatorDuty { From 0b6c0c562e31afbc73d3a2b7a86e9196cd5a3c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 18 Aug 2026 13:29:55 +0200 Subject: [PATCH 05/11] message/validation: derive the size-cap fork lead from networkconfig's prior window The pre-decode partial-signature cap hardcoded a one-epoch early flip as EstimatedCurrentEpoch() + 1, duplicating the SIP-43 prior-window width already named by networkconfig's boolePriorWindowEpochs. Expose the check as Network.BooleForkImminentOrActiveAtEpoch so a future widening of the prior window moves the cap flip with it. --- message/validation/partial_validation.go | 8 ++++---- networkconfig/network.go | 9 +++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index 1574cd99c6..586564e9bd 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -86,11 +86,11 @@ func (mv *messageValidator) validatePartialSignatureMessage( // case (~229 KB vs ~763 KB; the outer SignedSSVMessage decode that already happened is // bounded separately by MaxEncodedMsgSize). The cap is enforced before decoding, when the // message's own slot is not yet known, so unlike the other fork gates in this package the -// switch is wall-clock based — and flips one epoch before boole activation (mirroring -// SIP-43's one-epoch prior window) so that messages for post-fork slots arriving early -// (clock skew) are never rejected against the smaller cap. +// switch is wall-clock based — and flips at the start of SIP-43's prior window (one epoch +// before boole activation) so that messages for post-fork slots arriving early (clock +// skew) are never rejected against the smaller cap. func (mv *messageValidator) currentMaxEncodedPartialSignatureSize() int { - if mv.netCfg.BooleForkAtEpoch(mv.netCfg.EstimatedCurrentEpoch() + 1) { + if mv.netCfg.BooleForkImminentOrActiveAtEpoch(mv.netCfg.EstimatedCurrentEpoch()) { return maxEncodedPartialSignatureSize } return preForkMaxEncodedPartialSignatureSize diff --git a/networkconfig/network.go b/networkconfig/network.go index 5dc09b7655..8a5e4e0294 100644 --- a/networkconfig/network.go +++ b/networkconfig/network.go @@ -81,6 +81,15 @@ func (n Network) BooleForkAtSlot(slot phase0.Slot) bool { return n.BooleForkAtEpoch(n.EstimatedEpochAtSlot(slot)) } +// BooleForkImminentOrActiveAtEpoch reports whether epoch is inside the SIP-43 prior +// window of the Boole fork (boolePriorWindowEpochs before activation) or past activation. +// Use it for acceptance decisions that must flip to post-fork behavior as soon as +// post-fork traffic can legitimately appear, so the window width stays defined in one +// place. +func (n Network) BooleForkImminentOrActiveAtEpoch(epoch phase0.Epoch) bool { + return n.BooleForkAtEpoch(epoch + boolePriorWindowEpochs) +} + // 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 { From 9a706e9ded813f84d43efcbf6e6915cd63df22bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 18 Aug 2026 13:30:56 +0200 Subject: [PATCH 06/11] message/validation: key the partial-signature size cap off receivedAt The cap selector read the wall clock itself via EstimatedCurrentEpoch, so one message's validation made two time-based decisions from two different clock reads (validateSlotTime already keys off receivedAt, the single time.Now() sampled at the pubsub entry point). Thread receivedAt through instead. This also removes the dual-read nondeterminism from the fork-gate test: the fixtures and the gate now compute from the same fixed epoch, so an epoch boundary falling mid-test can no longer flip the expected cap. --- message/validation/partial_validation.go | 13 ++++--- message/validation/partial_validation_test.go | 35 +++++++++++++------ 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index 586564e9bd..db022bda95 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -30,7 +30,7 @@ func (mv *messageValidator) validatePartialSignatureMessage( ) { ssvMessage := signedSSVMessage.SSVMessage - if maxSize := mv.currentMaxEncodedPartialSignatureSize(); len(ssvMessage.Data) > maxSize { + if maxSize := mv.maxEncodedPartialSignatureSizeAt(receivedAt); len(ssvMessage.Data) > maxSize { e := ErrSSVDataTooBig e.got = len(ssvMessage.Data) e.want = maxSize @@ -79,18 +79,21 @@ func (mv *messageValidator) validatePartialSignatureMessage( return partialSignatureMessages, nil } -// currentMaxEncodedPartialSignatureSize returns the acceptance cap for encoded +// maxEncodedPartialSignatureSizeAt returns the acceptance cap for encoded // partial-signature message data (SSVMessage.Data). The post-fork (boole // AggregatorCommittee) worst case is ~3.3x the pre-fork one, so pre-fork the smaller cap // is enforced, bounding the inner PartialSignatureMessages decode at the pre-fork worst // case (~229 KB vs ~763 KB; the outer SignedSSVMessage decode that already happened is // bounded separately by MaxEncodedMsgSize). The cap is enforced before decoding, when the // message's own slot is not yet known, so unlike the other fork gates in this package the -// switch is wall-clock based — and flips at the start of SIP-43's prior window (one epoch +// switch is keyed off receivedAt — the message's pubsub arrival time, the same timestamp +// validateSlotTime uses, so one message's validation makes all its time-based decisions +// from a single clock read — and flips at the start of SIP-43's prior window (one epoch // before boole activation) so that messages for post-fork slots arriving early (clock // skew) are never rejected against the smaller cap. -func (mv *messageValidator) currentMaxEncodedPartialSignatureSize() int { - if mv.netCfg.BooleForkImminentOrActiveAtEpoch(mv.netCfg.EstimatedCurrentEpoch()) { +func (mv *messageValidator) maxEncodedPartialSignatureSizeAt(receivedAt time.Time) int { + epoch := mv.netCfg.EstimatedEpochAtSlot(mv.netCfg.EstimatedSlotAtTime(receivedAt)) + if mv.netCfg.BooleForkImminentOrActiveAtEpoch(epoch) { return maxEncodedPartialSignatureSize } return preForkMaxEncodedPartialSignatureSize diff --git a/message/validation/partial_validation_test.go b/message/validation/partial_validation_test.go index fb192489c7..69924a84ac 100644 --- a/message/validation/partial_validation_test.go +++ b/message/validation/partial_validation_test.go @@ -3,6 +3,7 @@ package validation import ( "math" "testing" + "time" "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" @@ -10,19 +11,31 @@ import ( "github.com/ssvlabs/ssv/networkconfig" ) -// TestCurrentMaxEncodedPartialSignatureSize pins the fork gate of the pre-decode +// testNetworkWithBoole returns a copy of TestNetwork with the Boole fork scheduled at +// booleEpoch. +func testNetworkWithBoole(booleEpoch phase0.Epoch) *networkconfig.Network { + ssv := *networkconfig.TestNetwork.SSV + ssv.Forks = networkconfig.SSVForks{Boole: booleEpoch} + return &networkconfig.Network{Beacon: networkconfig.TestNetwork.Beacon, SSV: &ssv} +} + +// testReceivedAtEpoch returns a receivedAt timestamp inside the given epoch. Deriving the +// timestamp from a fixed epoch (rather than sampling the wall clock) keeps the fork-gate +// tests fully deterministic: the epoch the gate computes from receivedAt is the epoch the +// fixtures were built for, no matter when the test runs. +func testReceivedAtEpoch(epoch phase0.Epoch) time.Time { + return networkconfig.TestNetwork.SlotStartTime(networkconfig.TestNetwork.FirstSlotAtEpoch(epoch)) +} + +// TestMaxEncodedPartialSignatureSizeAt pins the fork gate of the pre-decode // partial-signature size cap: the pre-fork cap applies while boole is unscheduled or more // than one epoch away, and the post-fork cap applies from one epoch before activation // (the early flip that protects boundary messages) onward. -func TestCurrentMaxEncodedPartialSignatureSize(t *testing.T) { +func TestMaxEncodedPartialSignatureSizeAt(t *testing.T) { t.Parallel() - cfgWithBoole := func(booleEpoch phase0.Epoch) *networkconfig.Network { - ssv := *networkconfig.TestNetwork.SSV - ssv.Forks = networkconfig.SSVForks{Boole: booleEpoch} - return &networkconfig.Network{Beacon: networkconfig.TestNetwork.Beacon, SSV: &ssv} - } - currentEpoch := networkconfig.TestNetwork.EstimatedCurrentEpoch() + const currentEpoch = phase0.Epoch(10) + receivedAt := testReceivedAtEpoch(currentEpoch) testCases := []struct { name string @@ -46,7 +59,7 @@ func TestCurrentMaxEncodedPartialSignatureSize(t *testing.T) { }, { name: "active fork uses the post-fork cap", - boole: 0, + boole: currentEpoch, want: maxEncodedPartialSignatureSize, }, } @@ -55,8 +68,8 @@ func TestCurrentMaxEncodedPartialSignatureSize(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - mv := &messageValidator{netCfg: cfgWithBoole(tc.boole)} - require.Equal(t, tc.want, mv.currentMaxEncodedPartialSignatureSize()) + mv := &messageValidator{netCfg: testNetworkWithBoole(tc.boole)} + require.Equal(t, tc.want, mv.maxEncodedPartialSignatureSizeAt(receivedAt)) }) } } From 0a77e301ae316f41dc860948a35428d8439c7e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 18 Aug 2026 13:31:50 +0200 Subject: [PATCH 07/11] protocol/v2: cover typed-nil runners in createRunner The nil guard only caught an interface nil in both type and value; a CreateRunnerFn returning a typed-nil runner (var cr *runner.CommitteeRunner; return cr, nil) sailed past it and panicked two lines later inside SetQBFTRoundTimerF, a method promoted from the embedded *BaseRunner. Move the timer wiring after the checked type assertions and add explicit typed-nil checks, so no method is called on the runner before its concrete type and non-nilness are established. --- protocol/v2/ssv/validator/committee.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/protocol/v2/ssv/validator/committee.go b/protocol/v2/ssv/validator/committee.go index 7a402b3a46..7555ccbed6 100644 --- a/protocol/v2/ssv/validator/committee.go +++ b/protocol/v2/ssv/validator/committee.go @@ -562,20 +562,31 @@ func (c *Committee) createRunner( // timer events under the right domain. Only GetRoleType() is read from this ID downstream, so // this is a consistency fix, not a behavior change today. runnerIdentifier := spectypes.NewMsgID(c.networkConfig.DomainTypeAtSlot(duty.DutySlot()), c.CommitteeMember.CommitteeID[:], role) - r.SetQBFTRoundTimerF(c.newQBFTRoundTimerF(runnerIdentifier)) + // The typed-nil checks below complement the interface-nil guard above: a CreateRunnerFn + // returning a nil *runner.CommitteeRunner behind a non-nil interface passes that guard but + // would panic on the first (promoted) method call — so no method is called on r until its + // concrete type and non-nilness are established. switch duty := duty.(type) { case *spectypes.CommitteeDuty: cr, ok := r.(*runner.CommitteeRunner) if !ok { return nil, fmt.Errorf("BUG: runner created for committee duty has type %T, expected *runner.CommitteeRunner", r) } + if cr == nil { + return nil, fmt.Errorf("BUG: CreateRunnerFn returned a typed-nil *runner.CommitteeRunner without error") + } + cr.SetQBFTRoundTimerF(c.newQBFTRoundTimerF(runnerIdentifier)) c.Runners[duty.DutySlot()] = cr case *spectypes.AggregatorCommitteeDuty: ar, ok := r.(*runner.AggregatorCommitteeRunner) if !ok { return nil, fmt.Errorf("BUG: runner created for aggregator committee duty has type %T, expected *runner.AggregatorCommitteeRunner", r) } + if ar == nil { + return nil, fmt.Errorf("BUG: CreateRunnerFn returned a typed-nil *runner.AggregatorCommitteeRunner without error") + } + ar.SetQBFTRoundTimerF(c.newQBFTRoundTimerF(runnerIdentifier)) c.AggregatorRunners[duty.DutySlot()] = ar default: c.logger.Panic("BUG: attempt to create committee runner with non-committee duty type", From 723c90796cd95fed7eb6f92e64596d5c2287f198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 18 Aug 2026 13:33:09 +0200 Subject: [PATCH 08/11] message/validation: test the fork-aware size cap through the validation path The fork-gate unit test exercised only the cap selector; nothing drove validatePartialSignatureMessage with a payload sized between the two caps, so the rejection branch (ErrSSVDataTooBig) was uncovered and a regression unwiring the fork-aware cap from the validation path would have passed. Add an end-to-end case: a between-caps payload is rejected against the pre-fork cap pre-fork, and passes the size gate (failing only at decode) once the fork is active. --- message/validation/partial_validation_test.go | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/message/validation/partial_validation_test.go b/message/validation/partial_validation_test.go index 69924a84ac..e1f3986dea 100644 --- a/message/validation/partial_validation_test.go +++ b/message/validation/partial_validation_test.go @@ -1,11 +1,14 @@ package validation import ( + "bytes" + "context" "math" "testing" "time" "github.com/attestantio/go-eth2-client/spec/phase0" + spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" "github.com/ssvlabs/ssv/networkconfig" @@ -73,3 +76,44 @@ func TestMaxEncodedPartialSignatureSizeAt(t *testing.T) { }) } } + +// TestPartialSignatureSizeCapEnforcedInValidation drives validatePartialSignatureMessage +// itself (not just the cap selector) with a payload sized between the two caps, guarding +// that the fork-aware cap stays wired into the validation path: pre-fork the payload is +// rejected as too big against the pre-fork cap; with the fork active the same payload +// passes the size gate and only fails later, at decoding. +func TestPartialSignatureSizeCapEnforcedInValidation(t *testing.T) { + t.Parallel() + + const currentEpoch = phase0.Epoch(10) + receivedAt := testReceivedAtEpoch(currentEpoch) + + betweenCaps := preForkMaxEncodedPartialSignatureSize + 1 + require.LessOrEqual(t, betweenCaps, maxEncodedPartialSignatureSize) + signedSSVMessage := &spectypes.SignedSSVMessage{ + SSVMessage: &spectypes.SSVMessage{Data: bytes.Repeat([]byte{1}, betweenCaps)}, + } + + t.Run("pre-fork rejects a payload above the pre-fork cap", func(t *testing.T) { + t.Parallel() + + mv := &messageValidator{netCfg: testNetworkWithBoole(currentEpoch + 2)} + _, err := mv.validatePartialSignatureMessage(context.Background(), signedSSVMessage, CommitteeInfo{}, "", "", receivedAt) + require.ErrorIs(t, err, ErrSSVDataTooBig) + + var valErr Error + require.ErrorAs(t, err, &valErr) + require.Equal(t, preForkMaxEncodedPartialSignatureSize, valErr.want, "rejection must be against the pre-fork cap") + }) + + t.Run("active fork lets the same payload past the size gate", func(t *testing.T) { + t.Parallel() + + mv := &messageValidator{netCfg: testNetworkWithBoole(currentEpoch)} + _, err := mv.validatePartialSignatureMessage(context.Background(), signedSSVMessage, CommitteeInfo{}, "", "", receivedAt) + require.NotErrorIs(t, err, ErrSSVDataTooBig) + // The garbage payload fails at the next step, decoding — proof the size gate + // (not the content) made the difference between the two cases. + require.ErrorIs(t, err, ErrUndecodableMessageData) + }) +} From cdedff8deb31793cbef7932855c9aea5cac3a9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 18 Aug 2026 13:33:36 +0200 Subject: [PATCH 09/11] protocol/v2/message: sweep spec-known roles in the FromString round-trip test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-trip test iterated a hardcoded role list, so a role newly added to the spec could gain its (test-forced) RunnerRoleToString case while RunnerRoleFromString silently stayed behind — nothing would fail until CommitteeRunnerRoleFromString rejected the exporter's own emitted string at runtime. Mirror the observability lockstep sweep: every value the spec knows a name for must round-trip through both mappers. --- protocol/v2/message/msg_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/protocol/v2/message/msg_test.go b/protocol/v2/message/msg_test.go index 6c80cf2201..83946c8964 100644 --- a/protocol/v2/message/msg_test.go +++ b/protocol/v2/message/msg_test.go @@ -115,4 +115,23 @@ func TestRunnerRoleFromString_ToString_RoundTrip(t *testing.T) { require.NoError(t, err, "round-trip failed for role %v (string %q)", role, s) assert.Equal(t, role, got) } + + // Sweep spec-known role values beyond the hardcoded list, so a role added to the spec + // must gain a RunnerRoleFromString case as well: the lockstep sweep in + // observability/utils/format_test.go already forces a RunnerRoleToString case for it, + // and without this sweep FromString could silently stay behind — leaving + // CommitteeRunnerRoleFromString to reject the exporter's own emitted string. The + // bound and the skip mirror that sweep: 15 is headroom over the spec's current max + // role value (6), and values the spec stringifies as "UNDEFINED" (unused or + // deprecated) are covered by the explicit list above instead. + for i := 0; i <= 15; i++ { + role := spectypes.RunnerRole(i) + if role.String() == "UNDEFINED" { + continue + } + s := RunnerRoleToString(role) + got, err := RunnerRoleFromString(s) + require.NoError(t, err, "spec-known role %d (%q) does not round-trip", role, s) + assert.Equal(t, role, got) + } } From 5b5d25760701b1f248e61d734380345d26c8b7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Tue, 18 Aug 2026 13:34:26 +0200 Subject: [PATCH 10/11] review nits: explain the sweep bound, the 217744-vs-217748 delta, and the panic asymmetry - format_test: say why the lockstep sweep stops at 15 (headroom over the spec's current max role value, roles are appended sequentially) - const.go: note the spec's 217748 includes the 4-byte SSZ offset of the dynamic Messages field that preForkMaxPartialSignatureMsgsSize (217744) deliberately omits - committee.go: say why the wrong-duty-type default stays a panic while the runner-type mismatches return errors (duty type is internally produced, CreateRunnerFn is injected) --- message/validation/const.go | 5 ++++- observability/utils/format_test.go | 11 +++++++---- protocol/v2/ssv/validator/committee.go | 3 +++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/message/validation/const.go b/message/validation/const.go index 06240653d4..1cda8f28cc 100644 --- a/message/validation/const.go +++ b/message/validation/const.go @@ -55,7 +55,10 @@ const ( // preForkMaxPartialSignatureMessages is the pre-boole worst case (RoleCommittee, // min(2*V, V+SYNC_COMMITTEE_SIZE) with the spec's V=1000 bound), matching pre-boole // ssv-spec v1.2.2 maxmsgsize.maxSizePartialSignatureMessages (1512 messages, 217748 - // bytes). Deliberately above the 1000 enforced before the boole convergence, which + // bytes — that spec figure includes the 4-byte SSZ offset of the dynamic Messages + // field, which preForkMaxPartialSignatureMsgsSize below omits, matching how + // maxPartialSignatureMsgsSize is computed; hence it evaluates to 217744). + // Deliberately above the 1000 enforced before the boole convergence, which // sat slightly below the spec's structural bound. The pinned (post-fork) spec no // longer publishes this constant, so const_test.go guards it against the hardcoded // v1.2.2 value instead. diff --git a/observability/utils/format_test.go b/observability/utils/format_test.go index e14db44d1a..f729bf2f0e 100644 --- a/observability/utils/format_test.go +++ b/observability/utils/format_test.go @@ -107,10 +107,13 @@ func TestRunnerRoleStringMappersLockstep(t *testing.T) { // Sweep beyond the explicit list so a role added to the spec — which FormatRunnerRole // picks up automatically via (RunnerRole).String() but message.RunnerRoleToString's - // hand-written switch would miss — fails here instead of drifting silently. Roles the - // spec does not know return "UNDEFINED" and are skipped: divergence on genuinely - // unknown values is intentional (the deprecated Alan roles also stringify to - // "UNDEFINED" in the spec, but they are covered by the explicit list above). + // hand-written switch would miss — fails here instead of drifting silently. The bound + // 15 is headroom over the spec's current max role value (6): roles are appended + // sequentially, so sweeping a few values past the end catches additions without the + // spec exporting a count. Roles the spec does not know return "UNDEFINED" and are + // skipped: divergence on genuinely unknown values is intentional (the deprecated Alan + // roles also stringify to "UNDEFINED" in the spec, but they are covered by the + // explicit list above). for i := 0; i <= 15; i++ { role := spectypes.RunnerRole(i) if role.String() == "UNDEFINED" { diff --git a/protocol/v2/ssv/validator/committee.go b/protocol/v2/ssv/validator/committee.go index 7555ccbed6..8c83c0b9f1 100644 --- a/protocol/v2/ssv/validator/committee.go +++ b/protocol/v2/ssv/validator/committee.go @@ -589,6 +589,9 @@ func (c *Committee) createRunner( ar.SetQBFTRoundTimerF(c.newQBFTRoundTimerF(runnerIdentifier)) c.AggregatorRunners[duty.DutySlot()] = ar default: + // Unlike the runner-type mismatches above, which guard the injected CreateRunnerFn, + // the duty type is produced by this package's own callers — a mismatch here is a + // local code bug, so it stays a loud panic rather than a returned error. c.logger.Panic("BUG: attempt to create committee runner with non-committee duty type", zap.String("type", fmt.Sprintf("%T", duty))) } From 83498ddd95d941dedf687833c60476470f21bbd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mom=C4=8Dilo=20Miladinovi=C4=87?= Date: Fri, 21 Aug 2026 10:45:53 +0200 Subject: [PATCH 11/11] message/validation: drop the fork-aware partial-sig cap for the static post-fork one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode cost is linear in payload bytes, so the smaller pre-fork cap never reduced the total load an attacker can induce per byte of spam — it only shrank the work per gossip-scoring penalty, which is negligible in absolute terms. Consensus messages on the same topics are capped at ~740 KB either way, so the tighter partial-signature lane never shrank the topic's per-message attack surface. Keep the post-fork cap (needed from activation anyway) as a static bound and remove the receivedAt-keyed switch, the pre-fork constants, and the now-unused prior-window epoch helper. --- message/validation/const.go | 14 --- message/validation/const_test.go | 18 +--- message/validation/partial_validation.go | 24 +---- message/validation/partial_validation_test.go | 99 +++---------------- networkconfig/network.go | 9 -- 5 files changed, 20 insertions(+), 144 deletions(-) diff --git a/message/validation/const.go b/message/validation/const.go index 1cda8f28cc..3c2eb29cc8 100644 --- a/message/validation/const.go +++ b/message/validation/const.go @@ -51,20 +51,6 @@ const ( partialSigMsgTypeSize = 8 // uint64 maxPartialSignatureMsgsSize = partialSigMsgTypeSize + slotSize + maxPartialSignatureMessages*partialSignatureMsgSize maxEncodedPartialSignatureSize = maxPartialSignatureMsgsSize + maxPartialSignatureMsgsSize/encodingOverheadDivisor + 4 - - // preForkMaxPartialSignatureMessages is the pre-boole worst case (RoleCommittee, - // min(2*V, V+SYNC_COMMITTEE_SIZE) with the spec's V=1000 bound), matching pre-boole - // ssv-spec v1.2.2 maxmsgsize.maxSizePartialSignatureMessages (1512 messages, 217748 - // bytes — that spec figure includes the 4-byte SSZ offset of the dynamic Messages - // field, which preForkMaxPartialSignatureMsgsSize below omits, matching how - // maxPartialSignatureMsgsSize is computed; hence it evaluates to 217744). - // Deliberately above the 1000 enforced before the boole convergence, which - // sat slightly below the spec's structural bound. The pinned (post-fork) spec no - // longer publishes this constant, so const_test.go guards it against the hardcoded - // v1.2.2 value instead. - preForkMaxPartialSignatureMessages = 1512 - preForkMaxPartialSignatureMsgsSize = partialSigMsgTypeSize + slotSize + preForkMaxPartialSignatureMessages*partialSignatureMsgSize - preForkMaxEncodedPartialSignatureSize = preForkMaxPartialSignatureMsgsSize + preForkMaxPartialSignatureMsgsSize/encodingOverheadDivisor + 4 ) const ( diff --git a/message/validation/const_test.go b/message/validation/const_test.go index 4fe6201336..7b254073bb 100644 --- a/message/validation/const_test.go +++ b/message/validation/const_test.go @@ -7,26 +7,14 @@ import ( "github.com/stretchr/testify/require" ) -// specV122MaxSizePartialSignatureMessages mirrors pre-boole ssv-spec v1.2.2's -// maxmsgsize.maxSizePartialSignatureMessages (1512 messages). It is unexported there and -// only one spec version can be pinned, so the value is hardcoded here to guard the -// pre-fork cap. -const specV122MaxSizePartialSignatureMessages = 217748 - // TestSizeCapsCoverSpecWorstCase guards against our hand-computed size caps // drifting below the pinned ssv-spec's worst-case message sizes. If this // fails after a spec bump, re-derive the corresponding const.go values. func TestSizeCapsCoverSpecWorstCase(t *testing.T) { - // The post-fork cap is compared against the spec's full-SSVMessage-envelope constant, - // which is over-conservative: the cap applies to SSVMessage.Data, the inner encoding. - // The pre-fork guard below compares against the inner v1.2.2 constant instead — the - // only partial-signature size constant that spec version published. + // The partial-signature cap is compared against the spec's full-SSVMessage-envelope + // constant, which is over-conservative: the cap applies to SSVMessage.Data, the inner + // encoding. require.GreaterOrEqual(t, maxEncodedPartialSignatureSize, maxmsgsize.MaxSizeSSVMessageFromPartialSignatureMessages) require.GreaterOrEqual(t, maxEncodedConsensusMsgSize, maxmsgsize.MaxSizeSSVMessageFromQBFTMessage) require.GreaterOrEqual(t, MaxEncodedMsgSize, maxmsgsize.MaxSizeSignedSSVMessageFromQBFTWith2Justification) - - // The pre-fork cap must cover the pre-boole spec's structural worst case but stay - // below the post-fork cap (otherwise the fork-aware switch would be pointless). - require.GreaterOrEqual(t, preForkMaxEncodedPartialSignatureSize, specV122MaxSizePartialSignatureMessages) - require.Less(t, preForkMaxEncodedPartialSignatureSize, maxEncodedPartialSignatureSize) } diff --git a/message/validation/partial_validation.go b/message/validation/partial_validation.go index db022bda95..649b031e07 100644 --- a/message/validation/partial_validation.go +++ b/message/validation/partial_validation.go @@ -30,10 +30,10 @@ func (mv *messageValidator) validatePartialSignatureMessage( ) { ssvMessage := signedSSVMessage.SSVMessage - if maxSize := mv.maxEncodedPartialSignatureSizeAt(receivedAt); len(ssvMessage.Data) > maxSize { + if len(ssvMessage.Data) > maxEncodedPartialSignatureSize { e := ErrSSVDataTooBig e.got = len(ssvMessage.Data) - e.want = maxSize + e.want = maxEncodedPartialSignatureSize return nil, e } @@ -79,26 +79,6 @@ func (mv *messageValidator) validatePartialSignatureMessage( return partialSignatureMessages, nil } -// maxEncodedPartialSignatureSizeAt returns the acceptance cap for encoded -// partial-signature message data (SSVMessage.Data). The post-fork (boole -// AggregatorCommittee) worst case is ~3.3x the pre-fork one, so pre-fork the smaller cap -// is enforced, bounding the inner PartialSignatureMessages decode at the pre-fork worst -// case (~229 KB vs ~763 KB; the outer SignedSSVMessage decode that already happened is -// bounded separately by MaxEncodedMsgSize). The cap is enforced before decoding, when the -// message's own slot is not yet known, so unlike the other fork gates in this package the -// switch is keyed off receivedAt — the message's pubsub arrival time, the same timestamp -// validateSlotTime uses, so one message's validation makes all its time-based decisions -// from a single clock read — and flips at the start of SIP-43's prior window (one epoch -// before boole activation) so that messages for post-fork slots arriving early (clock -// skew) are never rejected against the smaller cap. -func (mv *messageValidator) maxEncodedPartialSignatureSizeAt(receivedAt time.Time) int { - epoch := mv.netCfg.EstimatedEpochAtSlot(mv.netCfg.EstimatedSlotAtTime(receivedAt)) - if mv.netCfg.BooleForkImminentOrActiveAtEpoch(epoch) { - return maxEncodedPartialSignatureSize - } - return preForkMaxEncodedPartialSignatureSize -} - func (mv *messageValidator) validatePartialSignatureMessageSemantics( signedSSVMessage *spectypes.SignedSSVMessage, partialSignatureMessages *spectypes.PartialSignatureMessages, diff --git a/message/validation/partial_validation_test.go b/message/validation/partial_validation_test.go index e1f3986dea..1160bacc0a 100644 --- a/message/validation/partial_validation_test.go +++ b/message/validation/partial_validation_test.go @@ -3,114 +3,45 @@ package validation import ( "bytes" "context" - "math" "testing" "time" - "github.com/attestantio/go-eth2-client/spec/phase0" spectypes "github.com/ssvlabs/ssv-spec/types" "github.com/stretchr/testify/require" "github.com/ssvlabs/ssv/networkconfig" ) -// testNetworkWithBoole returns a copy of TestNetwork with the Boole fork scheduled at -// booleEpoch. -func testNetworkWithBoole(booleEpoch phase0.Epoch) *networkconfig.Network { - ssv := *networkconfig.TestNetwork.SSV - ssv.Forks = networkconfig.SSVForks{Boole: booleEpoch} - return &networkconfig.Network{Beacon: networkconfig.TestNetwork.Beacon, SSV: &ssv} -} - -// testReceivedAtEpoch returns a receivedAt timestamp inside the given epoch. Deriving the -// timestamp from a fixed epoch (rather than sampling the wall clock) keeps the fork-gate -// tests fully deterministic: the epoch the gate computes from receivedAt is the epoch the -// fixtures were built for, no matter when the test runs. -func testReceivedAtEpoch(epoch phase0.Epoch) time.Time { - return networkconfig.TestNetwork.SlotStartTime(networkconfig.TestNetwork.FirstSlotAtEpoch(epoch)) -} - -// TestMaxEncodedPartialSignatureSizeAt pins the fork gate of the pre-decode -// partial-signature size cap: the pre-fork cap applies while boole is unscheduled or more -// than one epoch away, and the post-fork cap applies from one epoch before activation -// (the early flip that protects boundary messages) onward. -func TestMaxEncodedPartialSignatureSizeAt(t *testing.T) { - t.Parallel() - - const currentEpoch = phase0.Epoch(10) - receivedAt := testReceivedAtEpoch(currentEpoch) - - testCases := []struct { - name string - boole phase0.Epoch - want int - }{ - { - name: "unscheduled fork keeps the pre-fork cap", - boole: math.MaxUint64, - want: preForkMaxEncodedPartialSignatureSize, - }, - { - name: "fork two epochs away keeps the pre-fork cap", - boole: currentEpoch + 2, - want: preForkMaxEncodedPartialSignatureSize, - }, - { - name: "cap flips one epoch before activation", - boole: currentEpoch + 1, - want: maxEncodedPartialSignatureSize, - }, - { - name: "active fork uses the post-fork cap", - boole: currentEpoch, - want: maxEncodedPartialSignatureSize, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - mv := &messageValidator{netCfg: testNetworkWithBoole(tc.boole)} - require.Equal(t, tc.want, mv.maxEncodedPartialSignatureSizeAt(receivedAt)) - }) - } -} - // TestPartialSignatureSizeCapEnforcedInValidation drives validatePartialSignatureMessage -// itself (not just the cap selector) with a payload sized between the two caps, guarding -// that the fork-aware cap stays wired into the validation path: pre-fork the payload is -// rejected as too big against the pre-fork cap; with the fork active the same payload -// passes the size gate and only fails later, at decoding. +// itself (not just the constant) with payloads around the cap, guarding that the size gate +// stays wired into the validation path: an oversized payload is rejected as too big, while +// one at the cap passes the size gate and only fails later, at decoding. func TestPartialSignatureSizeCapEnforcedInValidation(t *testing.T) { t.Parallel() - const currentEpoch = phase0.Epoch(10) - receivedAt := testReceivedAtEpoch(currentEpoch) - - betweenCaps := preForkMaxEncodedPartialSignatureSize + 1 - require.LessOrEqual(t, betweenCaps, maxEncodedPartialSignatureSize) - signedSSVMessage := &spectypes.SignedSSVMessage{ - SSVMessage: &spectypes.SSVMessage{Data: bytes.Repeat([]byte{1}, betweenCaps)}, - } + mv := &messageValidator{netCfg: networkconfig.TestNetwork} - t.Run("pre-fork rejects a payload above the pre-fork cap", func(t *testing.T) { + t.Run("payload above the cap is rejected", func(t *testing.T) { t.Parallel() - mv := &messageValidator{netCfg: testNetworkWithBoole(currentEpoch + 2)} - _, err := mv.validatePartialSignatureMessage(context.Background(), signedSSVMessage, CommitteeInfo{}, "", "", receivedAt) + signedSSVMessage := &spectypes.SignedSSVMessage{ + SSVMessage: &spectypes.SSVMessage{Data: bytes.Repeat([]byte{1}, maxEncodedPartialSignatureSize+1)}, + } + _, err := mv.validatePartialSignatureMessage(context.Background(), signedSSVMessage, CommitteeInfo{}, "", "", time.Time{}) require.ErrorIs(t, err, ErrSSVDataTooBig) var valErr Error require.ErrorAs(t, err, &valErr) - require.Equal(t, preForkMaxEncodedPartialSignatureSize, valErr.want, "rejection must be against the pre-fork cap") + require.Equal(t, maxEncodedPartialSignatureSize, valErr.want) }) - t.Run("active fork lets the same payload past the size gate", func(t *testing.T) { + t.Run("payload at the cap passes the size gate", func(t *testing.T) { t.Parallel() - mv := &messageValidator{netCfg: testNetworkWithBoole(currentEpoch)} - _, err := mv.validatePartialSignatureMessage(context.Background(), signedSSVMessage, CommitteeInfo{}, "", "", receivedAt) + signedSSVMessage := &spectypes.SignedSSVMessage{ + SSVMessage: &spectypes.SSVMessage{Data: bytes.Repeat([]byte{1}, maxEncodedPartialSignatureSize)}, + } + _, err := mv.validatePartialSignatureMessage(context.Background(), signedSSVMessage, CommitteeInfo{}, "", "", time.Time{}) require.NotErrorIs(t, err, ErrSSVDataTooBig) // The garbage payload fails at the next step, decoding — proof the size gate // (not the content) made the difference between the two cases. diff --git a/networkconfig/network.go b/networkconfig/network.go index 8a5e4e0294..5dc09b7655 100644 --- a/networkconfig/network.go +++ b/networkconfig/network.go @@ -81,15 +81,6 @@ func (n Network) BooleForkAtSlot(slot phase0.Slot) bool { return n.BooleForkAtEpoch(n.EstimatedEpochAtSlot(slot)) } -// BooleForkImminentOrActiveAtEpoch reports whether epoch is inside the SIP-43 prior -// window of the Boole fork (boolePriorWindowEpochs before activation) or past activation. -// Use it for acceptance decisions that must flip to post-fork behavior as soon as -// post-fork traffic can legitimately appear, so the window width stays defined in one -// place. -func (n Network) BooleForkImminentOrActiveAtEpoch(epoch phase0.Epoch) bool { - return n.BooleForkAtEpoch(epoch + boolePriorWindowEpochs) -} - // 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 {