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.
Goal
After committee QBFT decides, use the committee-decided
SlotVoteas the input to the slot's aggregation phase, withVotingContext.voteas fallback when no decision is available yet.This covers both aggregation inputs:
attestation_data_rootfrom the decided vote, including the Gloasattestation_data_indexblock_rootContext / 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_assignmentsgroups duties by SSV committee, but also builds globalattestation_committee_indexesandall_subnet_ids.build_consensus_data_for_all_committeesfetches one globalcommittee_index -> Attestationmap fromvoting_context.vote, and one global sync contribution map fromvoting_context.vote.block_root().fetch_aggregated_attestationsreconstructsAttestationDatafrom the supplied seed vote.AggregatorCommitteeConsensusDatais 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:The
VotingContextis 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_attestationssign_committee_sync_committee_signaturesRecord 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 returnsCommitteeDecisionConflictbefore the second caller can sign the conflicting vote.When constructing aggregation data, select one vote per SSV committee:
This returns the committee decision when present and falls back to the slot seed otherwise.
Represent Beacon API request identity with complete composite keys:
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
AggregatorCommitteeConsensusDatafrom its selected vote and the shared composite-key result maps, preserving go-ssv-compatible ordering for the same candidates and successfully fetched request identities:Acceptance criteria
attestation_data_rootis computed from that decided vote.attestation_data_indexis included in the aggregate-fetch root.block_rootwhen available.VotingContext.voteand does not block the duty.VotingContext, keyed byCommitteeId, and cannot grow across slots.AggregatorCommitteeConsensusData.versionremains stamped from the duty slot's fork through the assembly refactor (currentlyDataVersion::from(fork_name_at_epoch)inbuild_consensus_data_for_committee), so Gloas slots carryDataVersionGloas.block_root/source/targetwhen available.Tests
VotingContextreturns a cached committee decision or falls back to the slot seed.CommitteeDecisionConflictdata and preserves the first value.attestation_data_index.versionasDataVersionGloas; a pre-Gloas slot preserves the existing version.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 Lighthousevalidator_committee_indexas 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.