Skip to content

aggregator committee: extend value checks#2682

Open
nkryuchkov wants to merge 1 commit intoboole-forkfrom
aggregator-committee-extend-value-check
Open

aggregator committee: extend value checks#2682
nkryuchkov wants to merge 1 commit intoboole-forkfrom
aggregator-committee-extend-value-check

Conversation

@nkryuchkov
Copy link
Contributor

On hold until it's implemented in spec due to broken spec tests

@nkryuchkov
Copy link
Contributor Author

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 9, 2026

Greptile Overview

Greptile Summary

This PR extends aggregator committee validation by:

  • Building an allowlist of (validatorIndex -> committeeIndex) for aggregators and (validatorIndex -> subnetID) for sync committee contributors from the duty, and rejecting consensus values that include unexpected validators/indices (protocol/v2/ssv/value_check.go).
  • Tightening pre/post-consensus partial-signature validation for the aggregator committee role by computing the full set of expected signing roots and ensuring all incoming messages sign one of them (protocol/v2/ssv/runner/runner_validations.go).
  • Updating runner and test plumbing to pass a logger into validation and to provide duty/beacon dependencies to the new checker constructors.

Key issues to address before merge are around runtime safety (nil ValCheck if messages arrive out-of-order; constructor panics on nil duty) and a concrete unsigned-slot underflow in post-consensus validation when duty slot is 0.

Confidence Score: 3/5

  • This PR has moderate merge risk until a couple of runtime-safety bugs are fixed.
  • The changes are localized and conceptually consistent, but there are at least two concrete crash/reject scenarios introduced: ValCheck can be nil if consensus/post-consensus handling occurs before pre-consensus setup, and post-consensus slot validation underflows at slot 0. Fixing these should make the PR much safer.
  • protocol/v2/ssv/runner/aggregator_committee.go, protocol/v2/ssv/runner/runner_validations.go, protocol/v2/ssv/value_check.go

Important Files Changed

Filename Overview
protocol/v2/ssv/runner/aggregator_committee.go Moves AggregatorCommitteeRunner ValCheck initialization into ProcessPreConsensus; introduces risk of nil ValCheck if consensus/post-consensus processing happens before pre-consensus.
protocol/v2/ssv/runner/runner.go Threads logger into BaseRunner pre/post-consensus validation calls; no direct functional changes beyond signature updates.
protocol/v2/ssv/runner/runner_validations.go Extends pre/post-consensus root validation for AggregatorCommittee role using expected signing roots; introduces unsigned slot underflow bug in post-consensus slotIsRelevant when duty slot is 0.
protocol/v2/ssv/spectest/value_checker.go Updates spectest value checker construction to pass duty and beacon node into NewAggregatorCommitteeChecker; no functional changes besides constructor signature adaptation.
protocol/v2/ssv/testing/runner.go Updates testing runner construction to pass duty and beacon node into NewAggregatorCommitteeChecker for AggregatorCommittee role.
protocol/v2/ssv/value_check.go Expands aggregator committee value checker to enforce allowed aggregators/contributors from duty using subnet mapping; currently panics on nil duty in constructor and relies on duty being non-nil.

Sequence Diagram

sequenceDiagram
    participant Net as P2P Network
    participant R as AggregatorCommitteeRunner
    participant BR as BaseRunner
    participant BN as BeaconNode
    participant VC as AggregatorCommitteeChecker

    Net->>R: PreConsensus PartialSignatureMessages
    R->>BR: basePreConsensusMsgProcessing()
    BR->>BR: ValidatePreConsensusMsg(ctx, logger, runner, psigMsgs)
    BR->>R: expectedPreConsensusRoots(ctx, logger)
    R->>BN: fetch attestation/contribution data
    R-->>BR: expected signing roots (aggregators + contributions)
    BR->>BR: verifyExpectedSigningRoots(psigMsgs, expectedRoots)

    R->>VC: NewAggregatorCommitteeChecker(duty, BN)
    R->>BR: decide(ctx, logger, slot, consensusData, VC)

    Net->>R: Consensus SignedSSVMessage
    R->>BR: baseConsensusMsgProcessing(valCheck.CheckValue)

    Net->>R: PostConsensus PartialSignatureMessages
    R->>BR: basePostConsensusMsgProcessing()
    BR->>BR: ValidatePostConsensusMsg(ctx, logger, runner, psigMsgs)
    BR->>R: expectedPostConsensusRootsAndBeaconObjects(ctx, logger)
    BR->>BR: verifyExpectedSigningRoots(psigMsgs, expectedRoots)
Loading

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

6 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +90 to +93
func NewAggregatorCommitteeChecker(
duty *spectypes.AggregatorCommitteeDuty,
subnetProvider syncCommitteeSubnetIDProvider,
) ValueChecker {
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.

Comment on lines +584 to +587
r.ValCheck = ssv.NewAggregatorCommitteeChecker(
duty,
r.GetBeaconNode(),
)
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.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 9, 2026

Additional Comments (1)

protocol/v2/ssv/runner/runner_validations.go
Underflow when duty slot is 0

slotIsRelevant does minSlot := b.State.CurrentDuty.DutySlot() - 1 (phase0.Slot is unsigned). If the current duty slot is 0, this underflows to math.MaxUint64 and will make psigMsgs.Slot < minSlot always true, causing all post-consensus messages to be rejected.

Use a saturating subtraction (e.g. minSlot := b.State.CurrentDuty.DutySlot(); if minSlot > 0 { minSlot-- }) before comparing.

@codecov
Copy link

codecov bot commented Feb 9, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (boole-fork@c8c54ee). Learn more about missing BASE report.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

Copy link
Contributor

@GalRogozinski GalRogozinski left a comment

Choose a reason for hiding this comment

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

I don't think the changes are correct so rejecting for now

Comment on lines +37 to +58
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)
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.

Comment on lines +182 to +206
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)
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

Comment on lines +142 to +174

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

Base automatically changed from aggregator-committee to boole-fork February 16, 2026 09:01
@nkryuchkov nkryuchkov force-pushed the aggregator-committee-extend-value-check branch from 6c20a6a to bb08a8e Compare February 16, 2026 14:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants