Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions message/validation/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (doc clarity). The parenthetical "217748 bytes" is the spec's SSZ value (20 + 1512·144), whereas preForkMaxPartialSignatureMsgsSize on line 63 evaluates to 217744 — it omits the 4-byte SSZ offset for the dynamic Messages field. Harmless (the encoding-overhead margin absorbs it, and it matches how maxPartialSignatureMsgsSize is computed), but a reader diffing 217748 vs 217744 may pause. A half-sentence noting the local figure is pre-offset would help.

// 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 (
Expand Down
15 changes: 15 additions & 0 deletions message/validation/const_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,26 @@ 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.
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)
}
21 changes: 19 additions & 2 deletions message/validation/partial_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ func (mv *messageValidator) validatePartialSignatureMessage(
) {
ssvMessage := signedSSVMessage.SSVMessage

if len(ssvMessage.Data) > maxEncodedPartialSignatureSize {
if maxSize := mv.currentMaxEncodedPartialSignatureSize(); len(ssvMessage.Data) > maxSize {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage. The codecov bot flags this file's uncovered lines; the untested one is this rejection branch (return nil, e for ErrSSVDataTooBig). TestCurrentMaxEncodedPartialSignatureSize exercises the cap selector, but nothing drives validatePartialSignatureMessage with a payload sized between the two caps. Consider a small end-to-end case: ssvMessage.Data in (preForkMaxEncodedPartialSignatureSize, maxEncodedPartialSignatureSize] is rejected pre-fork and accepted past the size gate post-fork. That closes the coverage gap and guards that the fork-aware cap stays wired into the validation path (not just the helper).

e := ErrSSVDataTooBig
e.got = len(ssvMessage.Data)
e.want = maxEncodedPartialSignatureSize
e.want = maxSize
return nil, e
}

Expand Down Expand Up @@ -79,6 +79,23 @@ func (mv *messageValidator) validatePartialSignatureMessage(
return partialSignatureMessages, nil
}

// currentMaxEncodedPartialSignatureSize 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 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
}
return preForkMaxEncodedPartialSignatureSize
}

func (mv *messageValidator) validatePartialSignatureMessageSemantics(
signedSSVMessage *spectypes.SignedSSVMessage,
partialSignatureMessages *spectypes.PartialSignatureMessages,
Expand Down
62 changes: 62 additions & 0 deletions message/validation/partial_validation_test.go
Original file line number Diff line number Diff line change
@@ -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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (test robustness). currentEpoch is sampled from the wall clock here, but the helper samples EstimatedCurrentEpoch() again internally. If a real epoch boundary falls between the two reads, the "fork two epochs away" case flips to the post-fork cap and the assertion fails:

  • setup reads epoch Eboole = E+2, want = preFork
  • helper reads E+1BooleForkAtEpoch((E+1)+1) = (E+2) >= (E+2) = true → returns post-fork cap → mismatch

The window is ~microseconds within a multi-minute epoch, so this is astronomically rare rather than a real-world concern — but it is genuine nondeterminism. For full determinism, drive EstimatedCurrentEpoch() from a fixed/injected clock (or fixed genesis) instead of the live TestNetwork clock. The other three cases are immune.


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())
})
}
}
42 changes: 42 additions & 0 deletions observability/utils/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -78,3 +79,44 @@ 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)
}

// 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++ {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. The sweep upper bound 15 is arbitrary — a spec role added at value ≥ 16 would slip past this drift guard. Enum values are sequential today (0–6), so it isn't a practical gap, but a one-line note on why 15 (headroom) — or deriving the bound — would make the intent explicit.

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)
}
}
17 changes: 14 additions & 3 deletions protocol/v2/ssv/validator/committee.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -563,15 +566,23 @@ 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Uppercase runner error prefix

The new type-mismatch errors begin with BUG:, contrary to the repository convention that error messages remain lowercase and concise; the aggregator mismatch at line 574 repeats the same formatting issue.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}
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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional / non-blocking. The two runner type-mismatch cases above now return errors, but this default (wrong duty type) still logger.Panics. The distinction is defensible — duty type is internally controlled here, whereas the runner type comes from the injected CreateRunnerFn — but the asymmetry (BUG → return vs BUG → panic) is easy to trip over. A one-line comment on why this one stays a panic would preempt the question. Fine to leave as-is.

zap.String("type", fmt.Sprintf("%T", duty)))
}

return r, err
return r, nil
}

func (c *Committee) extractValidatorDuties(duty spectypes.Duty) []*spectypes.ValidatorDuty {
Expand Down