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
5 changes: 4 additions & 1 deletion protocol/v2/ssv/runner/aggregator_committee.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ func NewAggregatorCommitteeRunner(
Share: share,
QBFTController: qbftController,
},
ValCheck: ssv.NewAggregatorCommitteeChecker(),
beacon: beacon,
network: network,
signer: signer,
Expand Down Expand Up @@ -582,6 +581,10 @@ func (r *AggregatorCommitteeRunner) ProcessPreConsensus(
return fmt.Errorf("invalid aggregator committee consensus data: %w", err)
}

r.ValCheck = ssv.NewAggregatorCommitteeChecker(
duty,
r.GetBeaconNode(),
)
Comment on lines +584 to +587
Copy link
Contributor

Choose a reason for hiding this comment

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

Nil ValCheck until pre-consensus

NewAggregatorCommitteeRunner no longer initializes ValCheck, and it’s only set in ProcessPreConsensus right before decide. If any code path calls ProcessConsensus / ProcessPostConsensus before ProcessPreConsensus for the duty, r.ValCheck.CheckValue will panic.

If out-of-order message processing is possible in this runner (as it is elsewhere), initialize ValCheck in the constructor (with duty-independent checks) or guard ProcessConsensus/ProcessPostConsensus with a clear error when r.ValCheck == nil.

r.measurements.StartConsensus()
if err := r.BaseRunner.decide(
ctx,
Expand Down
4 changes: 2 additions & 2 deletions protocol/v2/ssv/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ func (b *BaseRunner) basePreConsensusMsgProcessing(ctx context.Context, logger *
// Reuse the existing span instead of generating new one to keep tracing-data lightweight.
span := trace.SpanFromContext(ctx)

if err := b.ValidatePreConsensusMsg(ctx, runner, signedMsg); err != nil {
if err := b.ValidatePreConsensusMsg(ctx, logger, runner, signedMsg); err != nil {
return false, nil, fmt.Errorf("invalid pre-consensus message: %w", err)
}

Expand Down Expand Up @@ -330,7 +330,7 @@ func (b *BaseRunner) basePostConsensusMsgProcessing(
// Reuse the existing span instead of generating new one to keep tracing-data lightweight.
span := trace.SpanFromContext(ctx)

if err := b.ValidatePostConsensusMsg(ctx, runner, signedMsg); err != nil {
if err := b.ValidatePostConsensusMsg(ctx, logger, runner, signedMsg); err != nil {
return false, nil, fmt.Errorf("invalid post-consensus message: %w", err)
}

Expand Down
84 changes: 70 additions & 14 deletions protocol/v2/ssv/runner/runner_validations.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
ssz "github.com/ferranbt/fastssz"
"github.com/pkg/errors"
specqbft "github.com/ssvlabs/ssv-spec/qbft"
"go.uber.org/zap"

spectypes "github.com/ssvlabs/ssv-spec/types"

Expand All @@ -18,6 +19,7 @@ import (

func (b *BaseRunner) ValidatePreConsensusMsg(
ctx context.Context,
logger *zap.Logger,
runner Runner,
psigMsgs *spectypes.PartialSignatureMessages,
) error {
Expand All @@ -28,27 +30,40 @@ func (b *BaseRunner) ValidatePreConsensusMsg(
return spectypes.WrapError(spectypes.NoRunningDutyErrorCode, ErrRunningDutyFinished)
}

// Validate the pre-consensus message differently depending on a message type.
validateMsg := func() error {
if err := b.validatePartialSigMsg(psigMsgs, b.State.CurrentDuty.DutySlot()); err != nil {
return err
if err := b.validatePartialSigMsg(psigMsgs, b.State.CurrentDuty.DutySlot()); err != nil {
return err
}

if runner.GetRole() == spectypes.RoleAggregatorCommittee {
aggRunner, ok := runner.(*AggregatorCommitteeRunner)
if !ok {
return fmt.Errorf("unexpected runner type %T for aggregator committee role", runner)
}

roots, domain, err := runner.expectedPreConsensusRootsAndDomain()
aggregatorMap, contributionMap, err := aggRunner.expectedPreConsensusRoots(ctx, logger)
if err != nil {
return fmt.Errorf("compute pre-consensus roots and domain: %w", err)
return fmt.Errorf("compute pre-consensus roots: %w", err)
}

return b.verifyExpectedRoot(ctx, runner, psigMsgs, roots, domain)
expectedRoots := make(map[[32]byte]struct{})
for _, root := range aggregatorMap {
expectedRoots[root] = struct{}{}
}
for _, indexMap := range contributionMap {
for _, root := range indexMap {
expectedRoots[root] = struct{}{}
}
}

return b.verifyExpectedSigningRoots(psigMsgs, expectedRoots)
Comment on lines +37 to +58
Copy link
Contributor

Choose a reason for hiding this comment

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

I understand that you are trying to fend off attacks where a single validator can complete quorum on a single root, thus preventing selection proofs on all other roots?

We had a discussion on this with @liorrutenberg and it was decided that we allow this attack to allow operators to have divergent views of validators (in case of EL issues).

I just discussed with @MatheusFranco99 and maybe the correct solution is to actually wait on the preconsensus stage if no aggregator passes the selection proof

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, I'm adding a PR for it.

}

if runner.GetRole() == spectypes.RoleAggregatorCommittee {
validateMsg = func() error {
return b.validatePartialSigMsg(psigMsgs, b.State.CurrentDuty.DutySlot())
}
roots, domain, err := runner.expectedPreConsensusRootsAndDomain()
if err != nil {
return fmt.Errorf("compute pre-consensus roots and domain: %w", err)
}

return validateMsg()
return b.verifyExpectedRoot(ctx, runner, psigMsgs, roots, domain)
}

// Verify each signature in container removing the invalid ones
Expand All @@ -63,7 +78,12 @@ func (b *BaseRunner) FallBackAndVerifyEachSignature(container *ssv.PartialSigCon
}
}

func (b *BaseRunner) ValidatePostConsensusMsg(ctx context.Context, runner Runner, psigMsgs *spectypes.PartialSignatureMessages) error {
func (b *BaseRunner) ValidatePostConsensusMsg(
ctx context.Context,
logger *zap.Logger,
runner Runner,
psigMsgs *spectypes.PartialSignatureMessages,
) error {
if !b.hasDutyAssigned() {
return spectypes.WrapError(spectypes.NoRunningDutyErrorCode, ErrNoDutyAssigned)
}
Expand Down Expand Up @@ -159,7 +179,31 @@ func (b *BaseRunner) ValidatePostConsensusMsg(ctx context.Context, runner Runner
// Use b.State.CurrentDuty.DutySlot() since CurrentDuty never changes for AggregatorCommitteeRunner
// by design, hence there is no need to store slot number on decidedValue for AggregatorCommitteeRunner.
expectedSlot := b.State.CurrentDuty.DutySlot()
return b.validatePartialSigMsg(psigMsgs, expectedSlot)
if err := b.validatePartialSigMsg(psigMsgs, expectedSlot); err != nil {
return err
}

aggRunner, ok := runner.(*AggregatorCommitteeRunner)
if !ok {
return fmt.Errorf("unexpected runner type %T for aggregator committee role", runner)
}

aggregatorMap, contributionMap, _, err := aggRunner.expectedPostConsensusRootsAndBeaconObjects(ctx, logger)
if err != nil {
return fmt.Errorf("compute post-consensus roots: %w", err)
}

expectedRoots := make(map[[32]byte]struct{})
for _, root := range aggregatorMap {
expectedRoots[root] = struct{}{}
}
for _, roots := range contributionMap {
for _, root := range roots {
expectedRoots[root] = struct{}{}
}
}

return b.verifyExpectedSigningRoots(psigMsgs, expectedRoots)
Comment on lines +182 to +206
Copy link
Contributor

Choose a reason for hiding this comment

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

I think whatever conclusion we have on preconsensus validation will be also valid here

}
}

Expand Down Expand Up @@ -235,3 +279,15 @@ func (b *BaseRunner) verifyExpectedRoot(
}
return nil
}

func (b *BaseRunner) verifyExpectedSigningRoots(
psigMsgs *spectypes.PartialSignatureMessages,
expectedRoots map[[32]byte]struct{},
) error {
for _, msg := range psigMsgs.Messages {
if _, ok := expectedRoots[msg.SigningRoot]; !ok {
return spectypes.NewError(spectypes.RootHashInvalidErrorCode, "unexpected signing root")
}
}
return nil
}
10 changes: 8 additions & 2 deletions protocol/v2/ssv/spectest/value_checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,10 @@ func (test *ValCheckSpecTest) valCheckF(signer ekm.BeaconSigner) func([]byte) er
)
return checker.CheckValue
case spectypes.RoleAggregatorCommittee:
checker := ssv.NewAggregatorCommitteeChecker()
checker := ssv.NewAggregatorCommitteeChecker(
spectestingutils.TestingAggregatorCommitteeDutyMixed(spec.DataVersionPhase0),
spectestingutils.NewTestingBeaconNode(),
)
return checker.CheckValue
default:
return nil
Expand Down Expand Up @@ -251,7 +254,10 @@ func createValueChecker(r runner.Runner, signerSource ...runner.Runner) ssv.Valu
expectedVote,
)
case *runner.AggregatorCommitteeRunner:
return ssv.NewAggregatorCommitteeChecker()
return ssv.NewAggregatorCommitteeChecker(
spectestingutils.TestingAggregatorCommitteeDutyMixed(spec.DataVersionPhase0),
spectestingutils.NewTestingBeaconNode(),
)

default:
return nil
Expand Down
10 changes: 8 additions & 2 deletions protocol/v2/ssv/testing/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ var ConstructBaseRunner = func(
valCheck = ssv.NewVoteChecker(km, spectestingutils.TestingDutySlot,
[]phase0.BLSPubKey{phase0.BLSPubKey(share.SharePubKey)}, vote)
case spectypes.RoleAggregatorCommittee:
valCheck = ssv.NewAggregatorCommitteeChecker()
valCheck = ssv.NewAggregatorCommitteeChecker(
spectestingutils.TestingAggregatorCommitteeDutyMixed(spec.DataVersionPhase0),
protocoltesting.NewTestingBeaconNodeWrapped(),
)
case spectypes.RoleProposer:
valCheck = ssv.NewProposerChecker(km, networkconfig.TestNetwork.Beacon,
(spectypes.ValidatorPK)(spectestingutils.TestingValidatorPubKey), spectestingutils.TestingValidatorIndex,
Expand Down Expand Up @@ -391,7 +394,10 @@ var ConstructBaseRunnerWithShareMap = func(
valCheck = ssv.NewVoteChecker(km, spectestingutils.TestingDutySlot,
sharePubKeys, vote)
case spectypes.RoleAggregatorCommittee:
valCheck = ssv.NewAggregatorCommitteeChecker()
valCheck = ssv.NewAggregatorCommitteeChecker(
spectestingutils.TestingAggregatorCommitteeDutyMixed(spec.DataVersionPhase0),
protocoltesting.NewTestingBeaconNodeWrapped(),
)
case spectypes.RoleProposer:
valCheck = ssv.NewProposerChecker(km, networkconfig.TestNetwork.Beacon,
shareInstance.ValidatorPubKey, shareInstance.ValidatorIndex, phase0.BLSPubKey(shareInstance.SharePubKey))
Expand Down
83 changes: 80 additions & 3 deletions protocol/v2/ssv/value_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,47 @@ func (v *voteChecker) CheckValue(value []byte) error {
return nil
}

type aggregatorCommitteeChecker struct{}
type aggregatorCommitteeChecker struct {
allowedAggregators map[phase0.ValidatorIndex]map[phase0.CommitteeIndex]struct{}
allowedContributors map[phase0.ValidatorIndex]map[uint64]struct{}
}

type syncCommitteeSubnetIDProvider interface {
SyncCommitteeSubnetID(phase0.CommitteeIndex) uint64
}

func NewAggregatorCommitteeChecker(
duty *spectypes.AggregatorCommitteeDuty,
subnetProvider syncCommitteeSubnetIDProvider,
) ValueChecker {
Comment on lines +90 to +93
Copy link
Contributor

Choose a reason for hiding this comment

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

Nil duty can panic

NewAggregatorCommitteeChecker dereferences duty.ValidatorDuties unconditionally, but AggregatorCommitteeRunner now sets r.ValCheck at runtime and the test/spectest helpers also call this with constructed duties. If duty is ever nil (e.g., runner constructed before duty assignment, or a future caller passes nil), this will panic rather than returning a validation error.

Consider returning a non-nil checker with empty allowlists (or returning an error from the constructor) when duty == nil, and ensure all call sites pass a real duty.

allowedAggregators := make(map[phase0.ValidatorIndex]map[phase0.CommitteeIndex]struct{})
allowedContributors := make(map[phase0.ValidatorIndex]map[uint64]struct{})

for _, vDuty := range duty.ValidatorDuties {
switch vDuty.Type {
case spectypes.BNRoleAggregator:
if _, ok := allowedAggregators[vDuty.ValidatorIndex]; !ok {
allowedAggregators[vDuty.ValidatorIndex] = make(map[phase0.CommitteeIndex]struct{})
}
allowedAggregators[vDuty.ValidatorIndex][(vDuty.CommitteeIndex)] = struct{}{}

case spectypes.BNRoleSyncCommitteeContribution:
if _, ok := allowedContributors[vDuty.ValidatorIndex]; !ok {
allowedContributors[vDuty.ValidatorIndex] = make(map[uint64]struct{})
}
for _, index := range vDuty.ValidatorSyncCommitteeIndices {
subnet := subnetProvider.SyncCommitteeSubnetID(phase0.CommitteeIndex(index))
allowedContributors[vDuty.ValidatorIndex][subnet] = struct{}{}
}
default:
// Other duty types are unexpected
}
}

func NewAggregatorCommitteeChecker() ValueChecker {
return &aggregatorCommitteeChecker{}
return &aggregatorCommitteeChecker{
allowedAggregators: allowedAggregators,
allowedContributors: allowedContributors,
}
}

func (v *aggregatorCommitteeChecker) CheckValue(value []byte) error {
Expand All @@ -96,6 +133,46 @@ func (v *aggregatorCommitteeChecker) CheckValue(value []byte) error {
return fmt.Errorf("invalid value: %w", err)
}

if len(cd.Aggregators) == 0 && len(cd.Contributors) == 0 {
return spectypes.WrapError(
spectypes.AggCommConsensusDataNoValidatorErrorCode,
fmt.Errorf("no aggregators or sync committee contributors in consensus data"),
)
}

for _, agg := range cd.Aggregators {
allowedByValidator, ok := v.allowedAggregators[agg.ValidatorIndex]
if !ok {
return spectypes.NewError(
spectypes.QBFTValueInvalidErrorCode,
fmt.Sprintf("unexpected aggregator validator %d", agg.ValidatorIndex),

Choose a reason for hiding this comment

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

hi, bringing back the divergent views concern from the parent PR (#2503, #2503 (comment) by @MatheusFranco99).

Since operators can have different views of the aggregator lists (i.e. different or failed BN responses, or some operators haven't processed a ValidatorAdded event yet), strict rejection here would stall consensus. The same issue applies to verifyExpectedSigningRoots I think -> an unknown validator's signing root causes the entire partial sig batch to be dropped, including valid sigs for validators the operator does know about.

Matheus's suggestion of only rejecting validators that are known to the operator but shouldn't have that duty rather than simply unknown seems like an ok tradeoff

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@shane-moore Hi, thanks
@MatheusFranco99 Can we implement this in spec? I'll adjust the implementation afterwards

Copy link
Contributor

Choose a reason for hiding this comment

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

Correct, we can't do validator id checks 👍
Regarding not having a duty for the validator, I think this is naturally handled by spec code.
Still, I'm adding a PR for making this clear. Will ask reviews soon.

)
}
if _, ok := allowedByValidator[phase0.CommitteeIndex(agg.CommitteeIndex)]; !ok {
return spectypes.NewError(
spectypes.QBFTValueInvalidErrorCode,
fmt.Sprintf("unexpected aggregator committee index %d for validator %d", agg.CommitteeIndex, agg.ValidatorIndex),
)
}
}

for _, contrib := range cd.Contributors {
allowedByValidator, ok := v.allowedContributors[contrib.ValidatorIndex]
if !ok {
return spectypes.NewError(
spectypes.QBFTValueInvalidErrorCode,
fmt.Sprintf("unexpected contributor validator %d", contrib.ValidatorIndex),
)
}
subnetID := contrib.CommitteeIndex
if _, ok := allowedByValidator[subnetID]; !ok {
return spectypes.NewError(
spectypes.QBFTValueInvalidErrorCode,
fmt.Sprintf("unexpected contributor subnet %d for validator %d", subnetID, contrib.ValidatorIndex),
)
}
}
Comment on lines +142 to +174
Copy link
Contributor

Choose a reason for hiding this comment

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

do you see any good motivation for having this check @MatheusFranco99?

We need more than F bad nodes for this to fail I think

Copy link
Contributor

Choose a reason for hiding this comment

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

Agree


return nil
}

Expand Down
Loading