-
Notifications
You must be signed in to change notification settings - Fork 150
Boole hardening: partial-sig size 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 4 commits
83200f6
98986ed
6efc025
947b954
0b6c0c5
9a706e9
0a77e30
723c907
cdedff8
5b5d257
83498dd
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 (
Contributor
Author
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. 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
Contributor
Author
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. 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 | ||
| } | ||
|
|
||
|
|
@@ -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 { | ||
|
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. Finding 2 · [MINOR] Use the receivedAt already threaded into the validator instead of a fresh wall-clock read
Contributor
Author
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. Fixed in 9a706e9. The fork-gate test builds fixtures from a fixed epoch, which also eliminates the dual-read flake iurii flagged.
Contributor
Author
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. Update: 83498dd drops the fork-aware cap entirely (see PR conversation), so the selector is gone. |
||
| if mv.netCfg.BooleForkAtEpoch(mv.netCfg.EstimatedCurrentEpoch() + 1) { | ||
|
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. Finding 1 · [MINOR] Reuse networkconfig's prior-window constant instead of hardcoding + 1 The gate hardcodes a one-epoch lead — Since
Contributor
Author
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. Fixed in 0b6c0c5. A future widening of the prior window moves the cap flip with it.
Contributor
Author
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. 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, | ||
|
|
||
| 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
Contributor
Author
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. Fixed in 9a706e9 — good catch, and Ovi's finding pointed at the same root: the cap is now keyed off
Contributor
Author
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. Update: this test is gone entirely now. The fork-aware cap was dropped for the static post-fork bound in 83498dd, per y0sher's benchmarks in the PR conversation. |
||
|
|
||
| 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
Contributor
Author
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. Fixed in 5b5d257 — the comment now says why 15: headroom over the spec's current max role value (6), since roles are appended sequentially.
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. Finding 3 · [MINOR] The sibling RunnerRoleFromString round-trip test still can't catch the drift the new sweep will now force The new lockstep sweep is the genuinely valuable half of item 7 — a role added to the spec now fails CI until someone adds it to The resulting hole: the spec adds
Contributor
Author
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. Fixed in cdedff8. |
||
| 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 { | ||
|
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. Finding 4 · [MINOR] The nil guard in createRunner doesn't cover a typed-nil runner, which still panics before the checked assertions The added guard, Execution reaches
Contributor
Author
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. Fixed in 0a77e30. |
||
| 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!
Contributor
Author
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 BUG: prefix is deliberate here — it marks invariant violations and matches the existing |
||
| } | ||
| 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
Contributor
Author
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. Fixed in 5b5d257. |
||
| 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.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.
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.
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.
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).