Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5b5d257 — the comment now notes the spec's 217748 includes the 4-byte SSZ offset of the dynamic Messages field, which the local pre-offset figure (217744) deliberately omits.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Update: the pre-fork constants and this comment were removed in 83498dd. The fork-aware cap was dropped for the static post-fork bound (see PR conversation).

// 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
11 changes: 11 additions & 0 deletions message/validation/const_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,22 @@ 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) {
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)
}
18 changes: 16 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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 723c907 — added the end-to-end case you suggested: a payload sized between the two caps is rejected against the pre-fork cap pre-fork (asserting the want field to pin which cap fired) and passes the size gate post-fork, failing only at decode.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Update: the fork-aware cap was dropped in 83498dd (see PR conversation). The end-to-end case survives in trimmed form: an oversized payload is rejected, one at the cap passes the gate and fails at decode.

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

Expand Down Expand Up @@ -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 {

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.

Finding 2 · [MINOR] Use the receivedAt already threaded into the validator instead of a fresh wall-clock read

currentMaxEncodedPartialSignatureSize() takes no arguments and reads the clock itself via EstimatedCurrentEpoch(). Its only caller, validatePartialSignatureMessage, already receives an authoritative receivedAt timestamp — sourced from a single time.Now() at the pubsub entry point — that validateSlotTime uses for its own time-based decision further down the same function. So one message's validation now makes two time-based decisions keyed off two different clock reads. Harmless today, but avoidable, and it forces the new test to sample EstimatedCurrentEpoch() a second time to build fixtures — reintroducing the dual-read flake iurii-ssv already flagged as astronomically rare, rather than eliminating it at the source. This is now the first thing every partial-signature message does on the committee path, where the cost of a second clock read is negligible, but avoidable at no cost by threading the existing timestamp through.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9a706e9.
The selector is now maxEncodedPartialSignatureSizeAt(receivedAt), so one message's validation makes all its time-based decisions from the single time.Now() at the pubsub entry point.

The fork-gate test builds fixtures from a fixed epoch, which also eliminates the dual-read flake iurii flagged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Update: 83498dd drops the fork-aware cap entirely (see PR conversation), so the selector is gone.

if mv.netCfg.BooleForkAtEpoch(mv.netCfg.EstimatedCurrentEpoch() + 1) {

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.

Finding 1 · [MINOR] Reuse networkconfig's prior-window constant instead of hardcoding + 1

The gate hardcodes a one-epoch lead — mv.netCfg.BooleForkAtEpoch(mv.netCfg.EstimatedCurrentEpoch() + 1) — justified as mirroring SIP-43's one-epoch prior window. That window is already a named constant in networkconfig, boolePriorWindowEpochs = phase0.Epoch(1). The two are exactly equivalent today, but the prior window is a network-wide protocol parameter, not a local constant: if it's ever widened, networkconfig changes and this cap silently doesn't, leaving a window where the node is subscribed to the post-fork topic set but still enforcing the pre-fork cap on messages arriving on it.

Since ErrSSVDataTooBig is reject:true, the failure mode is a gossipsub peer-score penalty against an honest peer, not a silently dropped message. This is exactly the duplicated-invariant-across-packages pattern that item 7 of this same PR adds a lockstep test to guard against elsewhere — the fix belongs in the same category of hardening.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0b6c0c5.
Added Network.BooleForkImminentOrActiveAtEpoch, which wraps boolePriorWindowEpochs, and the cap now calls that instead of the hardcoded + 1.

A future widening of the prior window moves the cap flip with it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Update: 83498dd drops the fork-aware cap entirely (see PR conversation), so the wrapper helper is gone too.

return maxEncodedPartialSignatureSize
}
return preForkMaxEncodedPartialSignatureSize
}

func (mv *messageValidator) validatePartialSignatureMessageSemantics(
signedSSVMessage *spectypes.SignedSSVMessage,
partialSignatureMessages *spectypes.PartialSignatureMessages,
Expand Down
27 changes: 27 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,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)
}
}
12 changes: 10 additions & 2 deletions protocol/v2/ssv/validator/committee.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The BUG: prefix is deliberate here — it marks invariant violations and matches the existing logger.Panic("BUG: ...") in this same function (and elsewhere in the repo). Please remember: the lowercase convention applies to ordinary error messages, not to the BUG: invariant marker.

}
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.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5b5d257.
Added the one-liner: duty type is produced internally, unlike the injected CreateRunnerFn, so the default stays a loud panic.

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