-
Notifications
You must be signed in to change notification settings - Fork 150
Boole hardening: fork-aware partial-sig cap, checked runner assertions, role-mapper lockstep test (#2978 items 3, 4, 7) #2989
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stage
Are you sure you want to change the base?
Changes from all commits
83200f6
98986ed
6efc025
947b954
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,10 +30,10 @@ func (mv *messageValidator) validatePartialSignatureMessage( | |
| ) { | ||
| ssvMessage := signedSSVMessage.SSVMessage | ||
|
|
||
| if len(ssvMessage.Data) > maxEncodedPartialSignatureSize { | ||
| if maxSize := mv.currentMaxEncodedPartialSignatureSize(); len(ssvMessage.Data) > maxSize { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( |
||
| e := ErrSSVDataTooBig | ||
| e.got = len(ssvMessage.Data) | ||
| e.want = maxEncodedPartialSignatureSize | ||
| e.want = maxSize | ||
| return nil, e | ||
| } | ||
|
|
||
|
|
@@ -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, | ||
|
|
||
| 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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor (test robustness).
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 |
||
|
|
||
| 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()) | ||
| }) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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++ { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit. The sweep upper bound |
||
| 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) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new type-mismatch errors begin with 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", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| zap.String("type", fmt.Sprintf("%T", duty))) | ||
| } | ||
|
|
||
| return r, err | ||
| return r, nil | ||
| } | ||
|
|
||
| func (c *Committee) extractValidatorDuties(duty spectypes.Duty) []*spectypes.ValidatorDuty { | ||
|
|
||
There was a problem hiding this comment.
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), whereaspreForkMaxPartialSignatureMsgsSizeon line 63 evaluates to 217744 — it omits the 4-byte SSZ offset for the dynamicMessagesfield. Harmless (the encoding-overhead margin absorbs it, and it matches howmaxPartialSignatureMsgsSizeis computed), but a reader diffing 217748 vs 217744 may pause. A half-sentence noting the local figure is pre-offset would help.