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

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,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 {

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

@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 — good catch, and Ovi's finding pointed at the same root: the cap is now keyed off receivedAt, and the test derives its timestamp from a fixed epoch, so the dual read (and the flake window with it) is gone entirely.

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: 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())
})
}
}
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.

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 says why 15: headroom over the spec's current max role value (6), since roles are appended sequentially.

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 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 message.RunnerRoleToString's switch. But protocol/v2/message has a third hand-written switch, RunnerRoleFromString, whose own drift guard is list-driven only, iterating a hardcoded slice of known roles.

The resulting hole: the spec adds RoleFoo. The new sweep fails; a developer adds a ToString case; the sweep passes. RunnerRoleFromString still has no case for the new role, and its round-trip test's hardcoded list doesn't mention it either, so that also passes — nothing fails anywhere. CommitteeRunnerRoleFromString then rejects the exporter's own emitted string with "unknown role", silently breaking the committee-traces role filter for that role. The PR closes the ToString-vs-ToString gap but leaves the ToString-vs-FromString gap open, and now more likely to be hit, since the new sweep actively nudges developers toward one switch without prompting about the other.

@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 cdedff8.
TestRunnerRoleFromString_ToString_RoundTrip now runs the same spec-known sweep, so a new spec role must gain a FromString case too or CI fails; the ToString-vs-FromString gap is closed alongside the ToString-vs-ToString one.

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 {

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 4 · [MINOR] The nil guard in createRunner doesn't cover a typed-nil runner, which still panics before the checked assertions

The added guard, if r == nil { return nil, fmt.Errorf(...) }, only catches an interface value that's nil in both type and value. A CreateRunnerFn that returns a typed nil (var cr *runner.CommitteeRunner; return cr, nil — the classic Go footgun, and a more likely mistake than a bare nil interface) sails past it, since the interface then carries a non-nil type word.

Execution reaches r.SetQBFTRoundTimerF(...) two lines later, a method promoted from the embedded *BaseRunner, which dereferences nil to reach the embedded field — a bare nil-pointer-dereference panic, just relocated two lines from the original interface-conversion panic site this change was meant to prevent. The stated goal — avoiding a bare panic from a mismatched CreateRunnerFn — is one step short of covering its own threat model, though likelihood is low since CreateRunnerFn is internal-only.

@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 0a77e30.
The timer wiring moved after the checked type assertions, and each case now checks typed nil explicitly, so no method is called on the runner before its concrete type and non-nilness are established.

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!

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

return r, err
return r, nil
}

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