Skip to content

feat(validator_store): use committee-decided vote for aggregation inputs #1113

Description

@shane-moore

Goal

After committee QBFT decides, use the committee-decided SlotVote as the input to the slot's aggregation phase, with VotingContext.vote as fallback when no decision is available yet.

This covers both aggregation inputs:

  • attestation aggregate fetch: compute attestation_data_root from the decided vote, including the Gloas attestation_data_index
  • sync contribution fetch: use the decided vote's block_root

Context / motivation

Follow-up requested in #1103 review: #1103 (review).

At #1103 head 54e7230, committee signing applies the QBFT-decided vote before signing, but aggregator data is still fetched from the local slot seed:

  • metadata_service::update_aggregation_assignments groups duties by SSV committee, but also builds global attestation_committee_indexes and all_subnet_ids.
  • build_consensus_data_for_all_committees fetches one global committee_index -> Attestation map from voting_context.vote, and one global sync contribution map from voting_context.vote.block_root().
  • fetch_aggregated_attestations reconstructs AttestationData from the supplied seed vote.
  • AggregatorCommitteeConsensusData is per SSV committee, so it can carry different aggregate bytes per SSV committee object, but the current global fetch map collapses before that point.

Under Gloas, the aggregate-fetch root must include the full attestation data the committee actually signed, including attestation_data_index. More generally, if committee QBFT decides a vote different from the local seed, aggregation should fetch data for what the cluster attested, not what this operator seeded locally. Otherwise the cluster can fail to fetch an aggregate, or aggregate data it did not sign. This is non-slashable, but it is a liveness/correctness gap.

Implementation approach

Store committee decisions in the slot-scoped VotingContext:

struct VotingContext {
    voting_assignments: Arc<VotingAssignments>,
    vote: SlotVote,
    decided_votes: Mutex<HashMap<CommitteeId, SlotVote>>,
}

The VotingContext is replaced with the slot's voting assignments, so the cache is naturally bounded to that slot and does not require a separate multi-slot map, retention constant, or pruning loop. The mutex is never held across .await.

Centralize decision recording in decide_committee_vote, which is shared by both committee signing paths:

  • sign_committee_attestations
  • sign_committee_sync_committee_signatures

Record only after Completed::Success. The first decision for a committee is authoritative, an identical repeat is idempotent, and a conflicting repeat preserves the first value and returns CommitteeDecisionConflict before the second caller can sign the conflicting vote.

When constructing aggregation data, select one vote per SSV committee:

voting_context.vote_for_committee(ssv_committee_id)

This returns the committee decision when present and falls back to the slot seed otherwise.

Represent Beacon API request identity with complete composite keys:

AggregateFetchKey {
    attestation_data_root,
    committee_index,
}

SyncContributionFetchKey {
    block_root,
    subnet_id,
}

Build the union of requests across SSV committees and deduplicate only identical composite keys. Committees with the same request share one Beacon API call, while committees with divergent decided votes remain isolated even when they reference the same beacon committee index or sync subnet.

Fetch aggregate attestations and sync contributions concurrently using the existing partial-result deadlines. Deterministically assemble each committee's AggregatorCommitteeConsensusData from its selected vote and the shared composite-key result maps, preserving go-ssv-compatible ordering for the same candidates and successfully fetched request identities:

  • aggregators by validator index
  • contributors by selection-proof signing root, then validator index
  • committee indexes and subnets in first-seen order from those sorted candidates
  • aggregate attestations and sync contributions in the corresponding index/subnet order

Acceptance criteria

  • When a decided vote exists for the slot's SSV committee, aggregate-fetch attestation_data_root is computed from that decided vote.
  • Under Gloas, the decided attestation_data_index is included in the aggregate-fetch root.
  • Sync contribution fetch uses the decided vote's block_root when available.
  • If the committee decision is not available when aggregation data is built, behavior falls back to VotingContext.vote and does not block the duty.
  • The decided-vote cache is scoped to the slot's VotingContext, keyed by CommitteeId, and cannot grow across slots.
  • The first decision is preserved. Identical repeats are accepted, while conflicting repeats fail closed before signing.
  • Two SSV committees in the same slot can use different decided votes even if they reference the same beacon committee index or sync subnet.
  • Identical Beacon API requests are deduplicated globally by their complete request identity.
  • Aggregator committee consensus data preserves deterministic go-ssv-compatible ordering and object association for the same available inputs.
  • AggregatorCommitteeConsensusData.version remains stamped from the duty slot's fork through the assembly refactor (currently DataVersion::from(fork_name_at_epoch) in build_consensus_data_for_committee), so Gloas slots carry DataVersionGloas.
  • Pre-Gloas behavior is preserved, except that aggregation may now converge on decided block_root/source/target when available.

Tests

  • VotingContext returns a cached committee decision or falls back to the slot seed.
  • Repeating an identical committee decision is idempotent.
  • A conflicting committee decision returns contextual CommitteeDecisionConflict data and preserves the first value.
  • Attestation-first and sync-first signing both populate the shared decided-vote cache, and both paths attach to the same committee QBFT instance.
  • Fork-specific aggregate-fetch data uses the pre-Electra committee index, Electra zero index, and Gloas decided attestation_data_index.
  • Consensus-data assembly at a Gloas slot stamps version as DataVersionGloas; a pre-Gloas slot preserves the existing version.
  • Composite aggregate and sync keys deduplicate only identical request identities.
  • Production consensus-data assembly excludes foreign results with the same bare committee index or subnet and preserves the exact ordering of aggregators, committee indexes, aggregate bytes, contributors, and sync contributions.
  • Empty fetch results return no consensus data, while aggregator-only and contributor-only results remain valid.

Notes

This cache only bridges a committee decision into the same slot's aggregation construction. It is not an attestation pool and stores only SlotVote, not full attestations or aggregate bytes.

The implementation intentionally defines parity over the same selected candidates and successfully fetched request identities. Beacon API retry scheduling and node-specific network outcomes are not part of the encoded consensus-data contract.

If implementation ever caches per-committee aggregate roots instead of SlotVote, be careful not to use Lighthouse validator_committee_index as the beacon committee index. That field is the validator's position within the committee.

Issues are directionally correct, not prescriptive; verify symbols at PR time.

Metadata

Metadata

Assignees

Labels

epbsePBS / EIP-7732 / Gloas implementation

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions