diff --git a/packages/beacon-node/src/api/impl/beacon/pool/index.ts b/packages/beacon-node/src/api/impl/beacon/pool/index.ts index 3e6adc3ed435..fadf4717abf0 100644 --- a/packages/beacon-node/src/api/impl/beacon/pool/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/pool/index.ts @@ -1,7 +1,14 @@ import {routes} from "@lodestar/api"; import {ApplicationMethods} from "@lodestar/api/server"; import {ForkPostElectra, ForkPreElectra, SYNC_COMMITTEE_SUBNET_SIZE, isForkPostElectra} from "@lodestar/params"; -import {Attestation, Epoch, SingleAttestation, isElectraAttestation, ssz} from "@lodestar/types"; +import { + Attestation, + Epoch, + SingleAttestation, + isElectraAttestation, + isElectraSingleAttestation, + ssz, +} from "@lodestar/types"; import { AttestationError, AttestationErrorCode, @@ -127,6 +134,17 @@ export function getBeaconPoolApi({ metrics?.opPool.attestationPool.apiInsertOutcome.inc({insertOutcome}); } + if (isElectraSingleAttestation(attestation)) { + const insertOutcome = chain.singleAttestationPool.add( + committeeIndex, + attestation, + attDataRootHex, + committeeValidatorIndex, + committeeSize + ); + metrics?.opPool.singleAttestationPool.apiInsertOutcome.inc({insertOutcome}); + } + if (isForkPostElectra(fork)) { chain.emitter.emit( routes.events.EventType.singleAttestation, diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 7296b5d02daa..b497b95e840c 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -69,6 +69,7 @@ import { import {ChainEvent, CheckpointHex, CommonBlockBody} from "../../../chain/index.js"; import {SCHEDULER_LOOKAHEAD_FACTOR} from "../../../chain/prepareNextSlot.js"; import {RegenCaller} from "../../../chain/regen/index.js"; +import {AggregationInfo} from "../../../chain/seenCache/seenAggregateAndProof.js"; import {validateApiAggregateAndProof} from "../../../chain/validation/index.js"; import {validateSyncCommitteeGossipContributionAndProof} from "../../../chain/validation/syncCommitteeContributionAndProof.js"; import {ZERO_HASH} from "../../../constants/index.js"; @@ -1273,17 +1274,12 @@ export function getValidatorApi( try { // TODO: Validate in batch const validateFn = () => validateApiAggregateAndProof(fork, chain, signedAggregateAndProof); - const {slot, beaconBlockRoot} = signedAggregateAndProof.message.aggregate.data; + const {slot, beaconBlockRoot, target} = signedAggregateAndProof.message.aggregate.data; // when a validator is configured with multiple beacon node urls, this attestation may come from another beacon node // and the block hasn't been in our forkchoice since we haven't seen / processing that block // see https://github.com/ChainSafe/lodestar/issues/5098 - const {indexedAttestation, committeeIndices, attDataRootHex} = await validateGossipFnRetryUnknownRoot( - validateFn, - network, - chain, - slot, - beaconBlockRoot - ); + const {indexedAttestation, committeeIndices, attDataRootHex, committeeIndex} = + await validateGossipFnRetryUnknownRoot(validateFn, network, chain, slot, beaconBlockRoot); const insertOutcome = chain.aggregatedAttestationPool.add( signedAggregateAndProof.message.aggregate, @@ -1291,6 +1287,18 @@ export function getValidatorApi( indexedAttestation.attestingIndices.length, committeeIndices ); + const aggregationInto: AggregationInfo = { + aggregationBits: signedAggregateAndProof.message.aggregate.aggregationBits, + trueBitCount: indexedAttestation.attestingIndices.length, + }; + chain.addSeenAgregatedAttestation( + slot, + target.epoch, + committeeIndex, + attDataRootHex, + aggregationInto, + false + ); metrics?.opPool.aggregatedAttestationPool.apiInsertOutcome.inc({insertOutcome}); const sentPeers = await network.publishBeaconAggregateAndProof(signedAggregateAndProof); diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index c0a70b8e7ee7..79f68fcf4f98 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -523,6 +523,8 @@ export function addAttestationPostElectra( const aggregationBits = BitArray.fromBoolArray(aggregationBools.slice(offset, offset + committee.length)); const trueBitCount = aggregationBits.getTrueBitIndexes().length; offset += committee.length; + // no need to add to SingleAttestationPool because this block could be reorged + // it will also slow down the block import process this.seenAggregatedAttestations.add( target.epoch, committeeIndices[i], diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 5f42f3849435..f83acf498fc1 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -24,6 +24,7 @@ import { BeaconBlock, BlindedBeaconBlock, BlindedBeaconBlockBody, + CommitteeIndex, Epoch, ExecutionPayload, Root, @@ -76,6 +77,7 @@ import { SyncCommitteeMessagePool, SyncContributionAndProofPool, } from "./opPools/index.js"; +import {SingleAttestationPool} from "./opPools/singleAttestationPool.js"; import {IChainOptions} from "./options.js"; import {PrepareNextSlotScheduler} from "./prepareNextSlot.js"; import {computeNewStateRoot} from "./produceBlock/computeNewStateRoot.js"; @@ -94,7 +96,7 @@ import { SeenSyncCommitteeMessages, } from "./seenCache/index.js"; import {SeenGossipBlockInput} from "./seenCache/index.js"; -import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js"; +import {AggregationInfo, SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js"; import {SeenAttestationDatas} from "./seenCache/seenAttestationData.js"; import {SeenBlockAttesters} from "./seenCache/seenBlockAttesters.js"; import {SeenBlockInputCache} from "./seenCache/seenBlockInput.js"; @@ -140,6 +142,7 @@ export class BeaconChain implements IBeaconChain { // Ops pool readonly attestationPool: AttestationPool; + readonly singleAttestationPool: SingleAttestationPool; readonly aggregatedAttestationPool: AggregatedAttestationPool; readonly syncCommitteeMessagePool: SyncCommitteeMessagePool; readonly syncContributionAndProofPool; @@ -248,6 +251,7 @@ export class BeaconChain implements IBeaconChain { this.opts?.preaggregateSlotDistance, metrics ); + this.singleAttestationPool = new SingleAttestationPool(metrics); this.aggregatedAttestationPool = new AggregatedAttestationPool(this.config, metrics); this.syncCommitteeMessagePool = new SyncCommitteeMessagePool( clock, @@ -992,6 +996,18 @@ export class BeaconChain implements IBeaconChain { return state.epochCtx.getShufflingAtEpoch(attEpoch); } + addSeenAgregatedAttestation( + slot: Slot, + targetEpoch: Epoch, + committeeIndex: CommitteeIndex, + attDataRoot: RootHex, + newItem: AggregationInfo, + checkIsKnown: boolean + ): void { + this.seenAggregatedAttestations.add(targetEpoch, committeeIndex, attDataRoot, newItem, checkIsKnown); + this.singleAttestationPool.seenAggregatedAttestation(slot, attDataRoot, committeeIndex, newItem.aggregationBits); + } + /** * `ForkChoice.onBlock` must never throw for a block that is valid with respect to the network * `justifiedBalancesGetter()` must never throw and it should always return a state. @@ -1138,6 +1154,7 @@ export class BeaconChain implements IBeaconChain { this.metrics?.clockSlot.set(slot); this.attestationPool.prune(slot); + this.singleAttestationPool.prune(slot); this.aggregatedAttestationPool.prune(slot); this.syncCommitteeMessagePool.prune(slot); this.seenSyncCommitteeMessages.prune(slot); diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 05b64ef33b49..61f571e42d38 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -11,6 +11,7 @@ import { import { BeaconBlock, BlindedBeaconBlock, + CommitteeIndex, Epoch, ExecutionPayload, Root, @@ -42,6 +43,7 @@ import {ForkchoiceCaller} from "./forkChoice/index.js"; import {LightClientServer} from "./lightClient/index.js"; import {AggregatedAttestationPool} from "./opPools/aggregatedAttestationPool.js"; import {AttestationPool, OpPool, SyncCommitteeMessagePool, SyncContributionAndProofPool} from "./opPools/index.js"; +import {SingleAttestationPool} from "./opPools/singleAttestationPool.js"; import {IChainOptions} from "./options.js"; import {AssembledBlockType, BlockAttributes, BlockType} from "./produceBlock/produceBlockBody.js"; import {IStateRegenerator, RegenCaller} from "./regen/index.js"; @@ -57,7 +59,7 @@ import { SeenSyncCommitteeMessages, } from "./seenCache/index.js"; import {SeenGossipBlockInput} from "./seenCache/index.js"; -import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js"; +import {AggregationInfo, SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js"; import {SeenAttestationDatas} from "./seenCache/seenAttestationData.js"; import {SeenBlockAttesters} from "./seenCache/seenBlockAttesters.js"; import {SeenBlockInputCache} from "./seenCache/seenBlockInput.js"; @@ -111,6 +113,7 @@ export interface IBeaconChain { // Ops pool readonly attestationPool: AttestationPool; + readonly singleAttestationPool: SingleAttestationPool; readonly aggregatedAttestationPool: AggregatedAttestationPool; readonly syncCommitteeMessagePool: SyncCommitteeMessagePool; readonly syncContributionAndProofPool: SyncContributionAndProofPool; @@ -260,6 +263,15 @@ export interface IBeaconChain { blockRef: BeaconBlock | BlindedBeaconBlock, validatorIds?: (ValidatorIndex | string)[] ): Promise; + + addSeenAgregatedAttestation( + slot: Slot, + targetEpoch: Epoch, + committeeIndex: CommitteeIndex, + attDataRoot: RootHex, + newItem: AggregationInfo, + checkIsKnown: boolean + ): void; } export type SSZObjectType = diff --git a/packages/beacon-node/src/chain/opPools/aggregatedAttestationPool.ts b/packages/beacon-node/src/chain/opPools/aggregatedAttestationPool.ts index 583b808d9e8e..c87a847bfa5b 100644 --- a/packages/beacon-node/src/chain/opPools/aggregatedAttestationPool.ts +++ b/packages/beacon-node/src/chain/opPools/aggregatedAttestationPool.ts @@ -45,8 +45,12 @@ type DataRootHex = string; type CommitteeIndex = number; +// TODO: move these types to getAttestationsForBlock.ts +export type CommitteeValidatorIndex = number; + // for pre-electra type AttestationWithScore = {attestation: Attestation; score: number}; + /** * for electra, this is to consolidate aggregated attestations of the same attestation data into a single attestation to be included in block * note that this is local definition in this file and it's NOT validator consolidation @@ -61,11 +65,20 @@ export type AttestationsConsolidation = { totalAttesters: number; }; +export enum ConsolidationType { + aggregated_attestation_pool = "aggregated_attestation_pool", + single_attestation_pool = "single_attestation_pool", +} + /** * This function returns not seen participation for a given epoch and slot and committee index. * Return null if all validators are seen or no info to check. */ -type GetNotSeenValidatorsFn = (epoch: Epoch, slot: Slot, committeeIndex: number) => Set | null; +export type GetNotSeenValidatorsFn = ( + epoch: Epoch, + slot: Slot, + committeeIndex: CommitteeIndex +) => Set | null; /** * Invalid attestation data reasons, this is useful to track in metrics. @@ -82,7 +95,7 @@ export enum InvalidAttestationData { * Validate attestation data for inclusion in a block. * Returns InvalidAttestationData if attestation data is invalid, null otherwise. */ -type ValidateAttestationDataFn = (attData: phase0.AttestationData) => InvalidAttestationData | null; +export type ValidateAttestationDataFn = (attData: phase0.AttestationData) => InvalidAttestationData | null; /** * Limit the max attestations with the same AttestationData. @@ -223,6 +236,13 @@ export class AggregatedAttestationPool { : this.getAttestationsForBlockPreElectra(fork, forkChoice, state); } + /** + * Get all slots in the pool in descending order. + */ + getStoredSlots(): Slot[] { + return Array.from(this.attestationGroupByIndexByDataHexBySlot.keys()).sort((a, b) => b - a); + } + /** * Get attestations to be included in a block pre-electra. Returns up to $MAX_ATTESTATIONS items */ @@ -335,8 +355,145 @@ export class AggregatedAttestationPool { return attestationsForBlock; } + /** + * Return AttestationsConsolidation array for a given slot. + * It also returns a map of committee index to not seen committee members so that we can find more + * AttestationsConsolidation[] from the single attestation pool. + */ + getAttestationsForBlockElectraBySlot( + slot: Slot, + fork: ForkName, + stateSlot: Slot, + effectiveBalanceIncrements: EffectiveBalanceIncrements, + notSeenValidatorsFn: GetNotSeenValidatorsFn, + validateAttestationDataFn: ValidateAttestationDataFn + ): { + consolidations: AttestationsConsolidation[]; + notSeenCommitteeMembersByIndex: Map | null>; + } { + const attestationGroupByIndexByDataHash = this.attestationGroupByIndexByDataHexBySlot.get(slot); + // should not happen, consumer loops through stored slots + if (!attestationGroupByIndexByDataHash) { + throw Error( + `No aggregated attestation pool for slot=${slot}, stored slots ${Array.from(this.attestationGroupByIndexByDataHexBySlot.keys()).join(",")}` + ); + } + + const epoch = computeEpochAtSlot(slot); + const consolidations: AttestationsConsolidation[] = []; + + // this is initially queried from the state, after scanning through AggregatedAttestationPool + // we will update it, so that we only scan not seen validators through SingleAttestationPool + // null means all seen + const notSeenCommitteeMembersByIndex = new Map | null>(); + + const inclusionDistance = stateSlot - slot; + let returnedAttestationsPerSlot = 0; + let totalAttestationsPerSlot = 0; + // CommitteeIndex 0 1 2 ... Consolidation (sameAttDataCons) + // Attestations att00 --- att10 --- att20 --- 0 (att 00 10 20) + // att01 --- - --- att21 --- 1 (att 01 __ 21) + // - --- - --- att22 --- 2 (att __ __ 22) + for (const attestationGroupByIndex of attestationGroupByIndexByDataHash.values()) { + // sameAttDataCons could be up to MAX_ATTESTATIONS_PER_GROUP_ELECTRA + const sameAttDataCons: AttestationsConsolidation[] = []; + const allAttestationGroups = Array.from(attestationGroupByIndex.values()); + if (allAttestationGroups.length === 0) { + this.metrics?.opPool.aggregatedAttestationPool.packedAttestations.emptyAttestationData.inc(); + continue; + } + + const invalidAttDataReason = validateAttestationDataFn(allAttestationGroups[0].data); + if (invalidAttDataReason !== null) { + this.metrics?.opPool.aggregatedAttestationPool.packedAttestations.invalidAttestationData.inc({ + reason: invalidAttDataReason, + }); + continue; + } + + for (const [committeeIndex, attestationGroup] of attestationGroupByIndex.entries()) { + // if we already have notSeenCommitteeMembers after looping through another attestation data, just use it instead of querying from state + let notSeenCommitteeMembers = notSeenCommitteeMembersByIndex.get(committeeIndex); + if (notSeenCommitteeMembers === undefined) { + // if not seen committee members is not set, we should query it from state + notSeenCommitteeMembers = notSeenValidatorsFn(epoch, slot, committeeIndex); + // do not set notSeenCommitteeMembersByIndex here, only do that for the last item of attestationsSameGroup below + } + + if (notSeenCommitteeMembers === null || notSeenCommitteeMembers.size === 0) { + this.metrics?.opPool.aggregatedAttestationPool.packedAttestations.seenCommittees.inc({inclusionDistance}); + continue; + } + + // cannot apply this optimization like pre-electra because consolidation needs to be done across committees: + // "after 2 slots, there are a good chance that we have 2 * MAX_ATTESTATIONS_ELECTRA attestations and break the for loop early" + + // TODO: Is it necessary to validateAttestation for: + // - Attestation committee index not within current committee count + // - Attestation aggregation bits length does not match committee length + // + // These properties should not change after being validate in gossip + // IF they have to be validated, do it only with one attestation per group since same data + // The committeeCountPerSlot can be precomputed once per slot + const getAttestationGroupResult = attestationGroup.getAttestationsForBlock( + fork, + effectiveBalanceIncrements, + notSeenCommitteeMembers, + MAX_ATTESTATIONS_PER_GROUP_ELECTRA + ); + const attestationsSameGroup = getAttestationGroupResult.result; + returnedAttestationsPerSlot += attestationsSameGroup.length; + totalAttestationsPerSlot += getAttestationGroupResult.totalAttestations; + + for (const [i, attestationNonParticipation] of attestationsSameGroup.entries()) { + // sameAttDataCons shares the same index for different committees so we use index `i` here + if (sameAttDataCons[i] === undefined) { + sameAttDataCons[i] = { + byCommittee: new Map(), + attData: attestationNonParticipation.attestation.data, + totalNewSeenEffectiveBalance: 0, + newSeenAttesters: 0, + notSeenAttesters: 0, + totalAttesters: 0, + }; + } + const sameAttDataCon = sameAttDataCons[i]; + // committeeIndex was from a map so it should be unique, but just in case + if (!sameAttDataCon.byCommittee.has(committeeIndex)) { + sameAttDataCon.byCommittee.set(committeeIndex, attestationNonParticipation); + sameAttDataCon.totalNewSeenEffectiveBalance += attestationNonParticipation.newSeenEffectiveBalance; + sameAttDataCon.newSeenAttesters += attestationNonParticipation.newSeenAttesters; + sameAttDataCon.notSeenAttesters += attestationNonParticipation.notSeenCommitteeMembers.size; + sameAttDataCon.totalAttesters += attestationGroup.committee.length; + } + + if (i === attestationsSameGroup.length - 1) { + // this is the last item of attestationsSameGroup which has the least not seen committee validator indices + // we will try to find them in SingleAttestationPool later + notSeenCommitteeMembersByIndex.set(committeeIndex, attestationNonParticipation.notSeenCommitteeMembers); + } + } + } // all committees are processed for this AttestationData + + // after all committees are processed, we have a list of sameAttDataCons + consolidations.push(...sameAttDataCons); + } // AggregatedAttestationPool is processed for this slot (with different AttestationData) + + this.metrics?.opPool.aggregatedAttestationPool.packedAttestations.returnedAttestations.set( + {inclusionDistance}, + returnedAttestationsPerSlot + ); + this.metrics?.opPool.aggregatedAttestationPool.packedAttestations.scannedAttestations.set( + {inclusionDistance}, + totalAttestationsPerSlot + ); + + return {consolidations, notSeenCommitteeMembersByIndex}; + } + /** * Get attestations to be included in an electra block. Returns up to $MAX_ATTESTATIONS_ELECTRA items + * TODO: remove this function, it's not used anymore after we implement `getAttestationsForBlock.ts` */ getAttestationsForBlockElectra( fork: ForkName, @@ -407,7 +564,7 @@ export class AggregatedAttestationPool { for (const [committeeIndex, attestationGroup] of attestationGroupByIndex.entries()) { const notSeenCommitteeMembers = notSeenValidatorsFn(epoch, slot, committeeIndex); if (notSeenCommitteeMembers === null || notSeenCommitteeMembers.size === 0) { - this.metrics?.opPool.aggregatedAttestationPool.packedAttestations.seenCommittees.inc(); + this.metrics?.opPool.aggregatedAttestationPool.packedAttestations.seenCommittees.inc({inclusionDistance}); continue; } @@ -597,7 +754,8 @@ interface AttestationWithIndex { trueBitsCount: number; } -type AttestationNonParticipant = { +// TODO: move this to `getAttestationsForBlock.ts` +export type AttestationNonParticipant = { attestation: Attestation; // this was `notSeenAttesterCount` in pre-electra // since electra, we prioritize total effective balance over attester count @@ -695,7 +853,7 @@ export class MatchingDataAttestationGroup { getAttestationsForBlock( fork: ForkName, effectiveBalanceIncrements: EffectiveBalanceIncrements, - notSeenCommitteeMembers: Set, + notSeenCommitteeMembers: Set, maxAttestation: number ): GetAttestationsGroupResult { const attestations: AttestationNonParticipant[] = []; @@ -731,7 +889,7 @@ export class MatchingDataAttestationGroup { private getMostValuableAttestation( fork: ForkName, effectiveBalanceIncrements: EffectiveBalanceIncrements, - notSeenCommitteeMembers: Set, + notSeenCommitteeMembers: Set, excluded: Set ): AttestationNonParticipant | null { if (notSeenCommitteeMembers.size === 0) { @@ -803,6 +961,7 @@ export function aggregateInto(attestation1: AttestationWithIndex, attestation2: * Electra and after: Block proposer consolidates attestations with the same * attestation data from different committee into a single attestation * https://github.com/ethereum/consensus-specs/blob/aba6345776aa876dad368cab27fbbb23fae20455/specs/_features/eip7549/validator.md?plain=1#L39 + * TODO: move this to `getAttestationsForBlock.ts` */ export function aggregateConsolidation({byCommittee, attData}: AttestationsConsolidation): electra.Attestation { const committeeBits = BitArray.fromBitLen(MAX_COMMITTEES_PER_SLOT); @@ -830,6 +989,7 @@ export function aggregateConsolidation({byCommittee, attData}: AttestationsConso /** * Pre-compute participation from a CachedBeaconStateAllForks, for use to check if an attestation's committee * has already attested or not. + * TODO: move to getAttestationsForBlock.ts */ export function getNotSeenValidatorsFn(state: CachedBeaconStateAllForks): GetNotSeenValidatorsFn { const stateSlot = state.slot; @@ -859,9 +1019,9 @@ export function getNotSeenValidatorsFn(state: CachedBeaconStateAllForks): GetNot const committee = state.epochCtx.getBeaconCommittee(slot, committeeIndex); const notSeenCommitteeMembers = new Set(); - for (const [i, validatorIndex] of committee.entries()) { + for (const [committeeValidatorIndex, validatorIndex] of committee.entries()) { if (!participants.has(validatorIndex)) { - notSeenCommitteeMembers.add(i); + notSeenCommitteeMembers.add(committeeValidatorIndex); } } return notSeenCommitteeMembers.size === 0 ? null : notSeenCommitteeMembers; @@ -940,6 +1100,7 @@ export function extractParticipationPhase0( * to avoid running the same shuffling validation multiple times. * * See also: https://github.com/ChainSafe/lodestar/issues/4333 + * TODO: move to getAttestationsForBlock.ts */ export function getValidateAttestationDataFn( forkChoice: IForkChoice, @@ -986,6 +1147,7 @@ export function getValidateAttestationDataFn( /** * Validate the shuffling of an attestation data against the current state. * Return `null` if the shuffling is valid, otherwise return an `InvalidAttestationData` reason. + * TODO: move to getAttestationsForBlock.ts */ function isValidShuffling( forkChoice: IForkChoice, diff --git a/packages/beacon-node/src/chain/opPools/attestationPool.ts b/packages/beacon-node/src/chain/opPools/attestationPool.ts index 3bae6733621d..c9e2ec8a7fa9 100644 --- a/packages/beacon-node/src/chain/opPools/attestationPool.ts +++ b/packages/beacon-node/src/chain/opPools/attestationPool.ts @@ -63,6 +63,7 @@ type CommitteeIndex = number | null; * `current_slot - SLOTS_RETAINED` will be removed and any future attestation with a slot lower * than that will also be refused. Pruning is done automatically based upon the attestations it * receives and it can be triggered manually. + * // TODO: rename this to FastAggregatePool */ export class AttestationPool { private readonly aggregateByIndexByRootBySlot = new MapDef< diff --git a/packages/beacon-node/src/chain/opPools/getAttestationsForBlock.ts b/packages/beacon-node/src/chain/opPools/getAttestationsForBlock.ts new file mode 100644 index 000000000000..92a2d1fe2670 --- /dev/null +++ b/packages/beacon-node/src/chain/opPools/getAttestationsForBlock.ts @@ -0,0 +1,212 @@ +import {ForkName, ForkSeq, MAX_ATTESTATIONS_ELECTRA, MIN_ATTESTATION_INCLUSION_DELAY} from "@lodestar/params"; +import {CachedBeaconStateAllForks, computeEpochAtSlot} from "@lodestar/state-transition"; +import {Attestation, CommitteeIndex, electra} from "@lodestar/types"; +import type {Metrics} from "../../metrics/index.js"; +import type {BeaconChain} from "../chain.js"; +import { + AttestationsConsolidation, + CommitteeValidatorIndex, + ConsolidationType, + ScannedSlotsTerminationReason, + aggregateConsolidation, + getNotSeenValidatorsFn, + getValidateAttestationDataFn, +} from "./aggregatedAttestationPool.js"; + +/** + * Get attestations to be included in a block. + * Post electra, for each slot: + * - get attestations from aggregated attestation pool, track not seen committee members from there + * - search for missing attestations of those committee members in single attestation pool + */ +export function getAttestationsForBlock( + this: BeaconChain, + fork: ForkName, + state: CachedBeaconStateAllForks +): Attestation[] { + const forkSeq = ForkSeq[fork]; + if (forkSeq < ForkSeq.electra) { + return this.aggregatedAttestationPool.getAttestationsForBlockPreElectra(fork, this.forkChoice, state); + } + + const stateSlot = state.slot; + const stateEpoch = state.epochCtx.epoch; + const statePrevEpoch = stateEpoch - 1; + + // it's important to use the same instance of these functions for both pools + // for the cache inside them to work well + const notSeenValidatorsFn = getNotSeenValidatorsFn(state); + const validateAttestationDataFn = getValidateAttestationDataFn(this.forkChoice, state); + + const aggregatedAttPoolSlotsDesc = this.aggregatedAttestationPool.getStoredSlots(); + const singleAttestationPoolSlots = this.singleAttestationPool.getStoredSlots(); + + // Track score of each `AttestationsConsolidation` from both pools + const consolidations = new Map(); + let scannedSlotsAggregatedAttestationPool = 0; + let scannedSlotsSingleAttestationPool = 0; + let stopReason: ScannedSlotsTerminationReason | null = null; + let totalAggregatedAttPoolConsolidations = 0; + let totalSingleAttestationPoolConsolidations = 0; + + slot: for (const slot of aggregatedAttPoolSlotsDesc) { + const epoch = computeEpochAtSlot(slot); + if (epoch < statePrevEpoch) { + // we process slot in desc order, this means next slot is not eligible, we should stop + stopReason = ScannedSlotsTerminationReason.SlotBeforePreviousEpoch; + break; + } + + // validateAttestation condition: Attestation target epoch not in previous or current epoch + if (!(epoch === stateEpoch || epoch === statePrevEpoch)) { + continue; // Invalid attestations + } + + // validateAttestation condition: Attestation slot not within inclusion window + if (!(slot + MIN_ATTESTATION_INCLUSION_DELAY <= stateSlot)) { + // this should not happen as slot is decreased so no need to track in metric + continue; // Invalid attestations + } + + const inclusionDistance = stateSlot - slot; + let aggregatedAttPoolConsolidations: AttestationsConsolidation[] = []; + let notSeenCommitteeMembersByIndex: Map | null>; + try { + const aggAttestationPoolResult = this.aggregatedAttestationPool.getAttestationsForBlockElectraBySlot( + slot, + fork, + state.slot, + state.epochCtx.effectiveBalanceIncrements, + notSeenValidatorsFn, + validateAttestationDataFn + ); + aggregatedAttPoolConsolidations = aggAttestationPoolResult.consolidations; + notSeenCommitteeMembersByIndex = aggAttestationPoolResult.notSeenCommitteeMembersByIndex; + } catch (e) { + this.logger.debug("Error getting AggregatedAttestations for block production", {slot}, e as Error); + continue; + } + scannedSlotsAggregatedAttestationPool++; + totalAggregatedAttPoolConsolidations += aggregatedAttPoolConsolidations.length; + + let singleAttConsolidations: AttestationsConsolidation[] = []; + if (singleAttestationPoolSlots.has(slot)) { + try { + singleAttConsolidations = this.singleAttestationPool.getAttestationsForBlockElectraBySlot( + slot, + state.slot, + notSeenCommitteeMembersByIndex, + state.epochCtx.effectiveBalanceIncrements, + notSeenValidatorsFn, + validateAttestationDataFn + ); + totalSingleAttestationPoolConsolidations += singleAttConsolidations.length; + } catch (e) { + this.logger.debug("Error getting SingleAttations for block production", {slot}, e as Error); + // no need to continue here, we can still process aggregated attestations + } + } + scannedSlotsSingleAttestationPool++; + + for (const {consolidation, type} of [ + ...aggregatedAttPoolConsolidations.map((c) => ({ + consolidation: c, + type: ConsolidationType.aggregated_attestation_pool, + })), + ...singleAttConsolidations.map((c) => ({consolidation: c, type: ConsolidationType.single_attestation_pool})), + ]) { + const score = consolidation.totalNewSeenEffectiveBalance / inclusionDistance; + consolidations.set(consolidation, {type, score}); + // previously we had a limit of 2 * MAX_ATTESTATIONS_ELECTRA, but now we have a limit of MAX_ATTESTATIONS_ELECTRA * 3 + // due to multiple SingleAttestations could be found per slot. This does not affect performance through. + if (consolidations.size >= MAX_ATTESTATIONS_ELECTRA * 3) { + stopReason = ScannedSlotsTerminationReason.MaxConsolidationReached; + break slot; + } + } + + // finished processing a slot + } + + this.metrics?.opPool.aggregatedAttestationPool.packedAttestations.totalConsolidations.set( + totalAggregatedAttPoolConsolidations + ); + this.metrics?.opPool.singleAttestationPool.packedAttestations.totalConsolidations.set( + totalSingleAttestationPoolConsolidations + ); + + const sortedConsolidationsByScore = Array.from(consolidations.entries()) + .sort((a, b) => b[1].score - a[1].score) + .map(([consolidation, {type}]) => ({consolidation, type})) + .slice(0, MAX_ATTESTATIONS_ELECTRA); + + // on chain aggregation is expensive, only do it after all + const aggregatedAttestationsPackedMetrics = this.metrics?.opPool.aggregatedAttestationPool.packedAttestations; + const singleAttestationPackedMetrics = this.metrics?.opPool.singleAttestationPool.packedAttestations; + const packedAttestations: electra.Attestation[] = new Array(sortedConsolidationsByScore.length); + + let aggregatedAttestationPoolIndex = 0; + let singleAttestationPoolIndex = 0; + for (const [i, {consolidation, type}] of sortedConsolidationsByScore.entries()) { + packedAttestations[i] = aggregateConsolidation(consolidation); + + // record metrics of packed attestations + const packedAttestationsMetrics = + type === ConsolidationType.aggregated_attestation_pool + ? aggregatedAttestationsPackedMetrics + : singleAttestationPackedMetrics; + const index = + type === ConsolidationType.aggregated_attestation_pool + ? aggregatedAttestationPoolIndex++ + : singleAttestationPoolIndex++; + packedAttestationsMetrics?.committeeCount.set({index}, consolidation.byCommittee.size); + packedAttestationsMetrics?.totalAttesters.set({index}, consolidation.totalAttesters); + packedAttestationsMetrics?.nonParticipation.set({index}, consolidation.notSeenAttesters); + packedAttestationsMetrics?.inclusionDistance.set({index}, stateSlot - packedAttestations[i].data.slot); + packedAttestationsMetrics?.newSeenAttesters.set({index}, consolidation.newSeenAttesters); + packedAttestationsMetrics?.totalEffectiveBalance.set({index}, consolidation.totalNewSeenEffectiveBalance); + } + + // reset unused indexes to avoid stale metrics to display on grafana + resetMetrics(this.metrics, aggregatedAttestationPoolIndex, singleAttestationPoolIndex); + + aggregatedAttestationsPackedMetrics?.packedAttestations.observe(packedAttestations.length); + + if (stopReason === null) { + stopReason = ScannedSlotsTerminationReason.ScannedAllSlots; + } + + aggregatedAttestationsPackedMetrics?.scannedSlots.set({reason: stopReason}, scannedSlotsAggregatedAttestationPool); + singleAttestationPackedMetrics?.scannedSlots.set({reason: stopReason}, scannedSlotsSingleAttestationPool); + + aggregatedAttestationsPackedMetrics?.poolSlots.set(aggregatedAttPoolSlotsDesc.length); + singleAttestationPackedMetrics?.poolSlots.set(singleAttestationPoolSlots.size); + + return packedAttestations; +} + +function resetMetrics( + metrics: Metrics | null, + aggregatedAttestationPoolIndex: number, + singleAttestationPoolIndex: number +): void { + const aggregatedAttestationsPackedMetrics = metrics?.opPool.aggregatedAttestationPool.packedAttestations; + for (let index = aggregatedAttestationPoolIndex; index < MAX_ATTESTATIONS_ELECTRA; index++) { + aggregatedAttestationsPackedMetrics?.committeeCount.set({index}, 0); + aggregatedAttestationsPackedMetrics?.totalAttesters.set({index}, 0); + aggregatedAttestationsPackedMetrics?.nonParticipation.set({index}, 0); + aggregatedAttestationsPackedMetrics?.inclusionDistance.set({index}, 0); + aggregatedAttestationsPackedMetrics?.newSeenAttesters.set({index}, 0); + aggregatedAttestationsPackedMetrics?.totalEffectiveBalance.set({index}, 0); + } + + const singleAttestationPackedMetrics = metrics?.opPool.singleAttestationPool.packedAttestations; + for (let index = singleAttestationPoolIndex; index < MAX_ATTESTATIONS_ELECTRA; index++) { + singleAttestationPackedMetrics?.committeeCount.set({index}, 0); + singleAttestationPackedMetrics?.totalAttesters.set({index}, 0); + singleAttestationPackedMetrics?.nonParticipation.set({index}, 0); + singleAttestationPackedMetrics?.inclusionDistance.set({index}, 0); + singleAttestationPackedMetrics?.newSeenAttesters.set({index}, 0); + singleAttestationPackedMetrics?.totalEffectiveBalance.set({index}, 0); + } +} diff --git a/packages/beacon-node/src/chain/opPools/index.ts b/packages/beacon-node/src/chain/opPools/index.ts index 03cd9f395c6d..caf47614d2bc 100644 --- a/packages/beacon-node/src/chain/opPools/index.ts +++ b/packages/beacon-node/src/chain/opPools/index.ts @@ -1,4 +1,5 @@ export {AggregatedAttestationPool} from "./aggregatedAttestationPool.js"; +export {SingleAttestationPool} from "./singleAttestationPool.js"; export {AttestationPool} from "./attestationPool.js"; export {SyncCommitteeMessagePool} from "./syncCommitteeMessagePool.js"; export {SyncContributionAndProofPool} from "./syncContributionAndProofPool.js"; diff --git a/packages/beacon-node/src/chain/opPools/singleAttestationPool.ts b/packages/beacon-node/src/chain/opPools/singleAttestationPool.ts new file mode 100644 index 000000000000..f14818f3e758 --- /dev/null +++ b/packages/beacon-node/src/chain/opPools/singleAttestationPool.ts @@ -0,0 +1,415 @@ +import {Signature, aggregateSignatures} from "@chainsafe/blst"; +import {BitArray} from "@chainsafe/ssz"; +import {ForkPostElectra, MAX_COMMITTEES_PER_SLOT} from "@lodestar/params"; +import {EffectiveBalanceIncrements, computeEpochAtSlot, computeSlotsSinceEpochStart} from "@lodestar/state-transition"; +import {Attestation, RootHex, SingleAttestation, Slot, phase0} from "@lodestar/types"; +import {MapDef} from "@lodestar/utils"; +import {Metrics} from "../../metrics/metrics.js"; +import { + AttestationNonParticipant, + AttestationsConsolidation, + GetNotSeenValidatorsFn, + ValidateAttestationDataFn, +} from "./aggregatedAttestationPool.js"; +import {InsertOutcome} from "./types.js"; +import {pruneBySlot, signatureFromBytesNoCheck} from "./utils.js"; + +/** Hex string of DataRoot */ +type DataRootHex = string; + +// TODO: dedup, same for below types +type CommitteeIndex = number; + +type CommitteeValidatorIndex = number; + +type CommitteeInfo = { + committeeSize: number; + attestations: Map>; +}; + +/** + * The memory usage of this pool is small because after an aggregated attestation is seen, + * all `SingleAttestation` for the same data root and committee index are removed. + * + * However we should bound it by total number of attestations just in case the network is in unfinality state. + * Each SingleAttestation includes ~316 bytes: + * - CommitteeIndex: 8 bytes + * - ValidatorIndex: 8 bytes + * - AttestationData: this is shared via SeenAttestationDatas so we don't count + * - Signature: 96 bytes, but NodeJS has some overhead so could be up to 300 bytes + * + * In the worse case, we don't want to store more than 150k attestations because: + * - it keeps the memory usage of the pool under 50MB + * - it does not drag performance of aggregated attestation validation due to `seenAggregatedAttestation` method + * - it's more than enough to include SingleAttestations in block production due to the max capacity of 8 attestations in block + * + * As of Aug 2025: + * - we store less than 40k attestations in the pool for a mainnet-sas node + * - we store less than 1k attestations in the pool for a regular mainnet node + * - we store less than 125k attestations in the pool for a hoodi-sas node + * - we store less than 6k attestations in the pool for a regular hoodi node + */ +const MAX_ATTESTATIONS_RETAINED = 150_000; + +/** + * A pool of `SingleAttestation` that is specially designed to block production. + */ +export class SingleAttestationPool { + /** + * This is used to store attestations for block production + * + * The structure is: + * - slot -> dataRootHex -> committeeIndex -> CommitteeInfo + */ + private readonly committeeByIndexByRootBySlot = new MapDef< + Slot, + Map> + >(() => new Map>()); + + private lowestPermissibleSlot = 0; + + constructor(private readonly metrics: Metrics | null = null) { + metrics?.opPool.singleAttestationPool.size.addCollect(() => this.onScrapeMetrics(metrics)); + } + + /** + * Store SingleAttestations in the pool to be used for block production. + * This pool assumes consumer checked duplicate attestation per epoch checked + */ + add( + committeeIndex: CommitteeIndex, + attestation: SingleAttestation, + attDataRootHex: RootHex, + committeeValidatorIndex: CommitteeValidatorIndex, + committeeSize: number + ): InsertOutcome { + const slot = attestation.data.slot; + // Reject any attestations that are too old. + if (slot < this.lowestPermissibleSlot) { + return InsertOutcome.Old; + } + + const committeeByIndexByRoot = this.committeeByIndexByRootBySlot.getOrDefault(slot); + + let committeeByIndex = committeeByIndexByRoot.get(attDataRootHex); + if (committeeByIndex === undefined) { + committeeByIndex = new Map(); + committeeByIndexByRoot.set(attDataRootHex, committeeByIndex); + } + let committeeInfo = committeeByIndex.get(committeeIndex); + if (committeeInfo == null) { + committeeInfo = { + committeeSize, + attestations: new Map>(), + }; + committeeByIndex.set(committeeIndex, committeeInfo); + } + + committeeInfo.attestations.set(committeeValidatorIndex, attestation); + return InsertOutcome.NewData; + } + + /** + * An aggregated attestations was seen, so we remove all SingleAttestations respective to that data + * as it's useless for us. In block production it'll prioritize aggregated attestations before reaching this pool. + */ + seenAggregatedAttestation( + slot: Slot, + attDataRootHex: RootHex, + committeeIndex: CommitteeIndex, + aggregationBits: BitArray + ): void { + const committeeByIndexByRoot = this.committeeByIndexByRootBySlot.get(slot); + if (committeeByIndexByRoot == null) { + // no SingleAttestation for this slot + return; + } + + const committeeByIndex = committeeByIndexByRoot.get(attDataRootHex); + if (committeeByIndex == null) { + // no SingleAttestation for this data root + return; + } + + const committeeInfo = committeeByIndex.get(committeeIndex); + if (committeeInfo == null) { + // no SingleAttestation for this committee index + return; + } + + // remove SingleAttestation for this committee index, we have it in AggregatedAttestationPool + const singleAttestations = committeeInfo.attestations; + // loop through singleAttestations instead of aggregationBits because after the 1st seen aggregated attestation, + // there is few/zero SingleAttestations left for the same committee index + for (const committeeValidatorIndex of singleAttestations.keys()) { + if (aggregationBits.get(committeeValidatorIndex)) { + singleAttestations.delete(committeeValidatorIndex); + } + } + } + + /** + * Get all slots storing SingleAttestations in the pool. + */ + getStoredSlots(): Set { + return new Set(this.committeeByIndexByRootBySlot.keys()); + } + + /** + * Search for SingleAttestations of not seen committee members for a specific slot. + * Before reaching this pool, we searched for aggregated attestations in AggregatedAttestationPool. + */ + getAttestationsForBlockElectraBySlot( + slot: Slot, + stateSlot: Slot, + notSeenCommitteeMembersByIndex: Map | null>, + effectiveBalanceIncrements: EffectiveBalanceIncrements, + notSeenValidatorsFn: GetNotSeenValidatorsFn, + validateAttDataFn: ValidateAttestationDataFn + ): AttestationsConsolidation[] { + const committeeByIndexByRoot = this.committeeByIndexByRootBySlot.get(slot); + if (committeeByIndexByRoot == null || committeeByIndexByRoot.size === 0) { + // by default, a node has to subscribe to at least 2 random subnets and we loop through stored slots only + // so throw error instead of returning empty here + throw Error(`No attestation for slot ${slot} in attestation pool`); + } + + const inclusionDistance = stateSlot - slot; + const epoch = computeEpochAtSlot(slot); + const result: AttestationsConsolidation[] = []; + const packedAttestationsMetrics = this.metrics?.opPool.singleAttestationPool.packedAttestations; + + // CommitteeIndex 0 1 2 ... Consolidation (sameAttDataCons) + // Attestations att00 --- att10 --- att20 --- 0 (att 00 10 20) + for (const committeeByCommitteeIndex of committeeByIndexByRoot.values()) { + // same attestation data root, different committeeIndex + if (committeeByCommitteeIndex.size === 0) { + // it's a bug if there is no committee for a specific data root of slot + packedAttestationsMetrics?.emptyCommittee.inc({inclusionDistance}); + continue; + } + + const firstCommittee = Array.from(committeeByCommitteeIndex.values())[0]; + if (firstCommittee.attestations.size === 0) { + // it's a bug if there is no SingleAttestation for the first committee + packedAttestationsMetrics?.emptyAttestation.inc({inclusionDistance}); + continue; + } + + const firstAttestation = Array.from(firstCommittee.attestations.values())[0]; + const attestationData = firstAttestation.data; + + const invalidAttDataReason = validateAttDataFn(attestationData); + // null means valid + if (invalidAttDataReason) { + packedAttestationsMetrics?.invalidAttestationData.inc({ + reason: invalidAttDataReason, + }); + continue; + } + + // in AggregatedAttestationPool, sameAttDataCons could be up to MAX_ATTESTATIONS_PER_GROUP_ELECTRA because a matching group returns multiple aggregated attestations + // here with the same attestation data, we aggregate attestations of the same committee, then consolidate cross-committee AggregateAttestations to a single AttestationsConsolidation + const sameAttDataCon: AttestationsConsolidation = { + byCommittee: new Map(), + attData: attestationData, + totalNewSeenEffectiveBalance: 0, + newSeenAttesters: 0, + notSeenAttesters: 0, + totalAttesters: 0, + }; + for (const [committeeIndex, committeeInfo] of committeeByCommitteeIndex.entries()) { + let notSeenMembers = notSeenCommitteeMembersByIndex.get(committeeIndex); + // in AggregatedAttestationPool we may not populate value for some committees due to its data, query from state just in case + if (notSeenMembers === undefined) { + notSeenMembers = notSeenValidatorsFn(epoch, slot, committeeIndex); + } + + // null means all seen + if (notSeenMembers === null || notSeenMembers.size === 0) { + packedAttestationsMetrics?.seenCommittees.inc({inclusionDistance}); + continue; + } + + const committeeSize = committeeInfo.committeeSize; + const attestationsByCommitteeValidatorIndex = committeeInfo.attestations; + + const sameComitteeAttestations = new Map(); + let newSeenEffectiveBalance = 0; + let newSeenAttesters = 0; + for (const notSeenCommitteeValidatorIndex of notSeenMembers) { + const attestation = attestationsByCommitteeValidatorIndex.get(notSeenCommitteeValidatorIndex); + if (attestation == null) { + // we don't have this missing SingleAttestation + continue; + } + newSeenEffectiveBalance += effectiveBalanceIncrements[attestation.attesterIndex]; + newSeenAttesters++; + sameComitteeAttestations.set(notSeenCommitteeValidatorIndex, attestation); + // no need to search for the same notSeenCommitteeValidatorIndex in the next loop of attestation data root + notSeenMembers.delete(notSeenCommitteeValidatorIndex); + } // end looping through not seen committee members + + if (sameComitteeAttestations.size === 0) { + // no missing SingleAttestations for this committeeIndex, expect this to happen a lot of times so not sure if we should track metrics here + continue; + } + + const aggregatedAttestation = aggregateAttestations(sameComitteeAttestations, committeeIndex, committeeSize); + const attestationNonParticipation: AttestationNonParticipant = { + attestation: aggregatedAttestation, + newSeenEffectiveBalance, + newSeenAttesters, + notSeenCommitteeMembers: notSeenMembers, + }; + + sameAttDataCon.byCommittee.set(committeeIndex, attestationNonParticipation); + sameAttDataCon.totalNewSeenEffectiveBalance += attestationNonParticipation.newSeenEffectiveBalance; + sameAttDataCon.newSeenAttesters += attestationNonParticipation.newSeenAttesters; + sameAttDataCon.notSeenAttesters += attestationNonParticipation.notSeenCommitteeMembers.size; + sameAttDataCon.totalAttesters += committeeSize; + } // finish looping through all committee indices of the same attestation data + + if (sameAttDataCon.byCommittee.size > 0 && sameAttDataCon.newSeenAttesters > 0) { + // we have at least one committee with new seen attesters + result.push(sameAttDataCon); + } + } // finish looping through all attestation data roots of the same slot + + packedAttestationsMetrics?.returnedAttestations.set({inclusionDistance}, result.length); + + // we bound returned AttestationsConsolidation in the consumer for each slot, so just return as many as possible + return result; + } + + /** + * Remove any attestations with a slot lower than `current_slot - MAX_SLOTS_RETAINED`. + * Remove more slots until we have less than `MAX_ATTESTATIONS_RETAINED` attestations in the pool or at least `MIN_SLOTS_RETAINED` slots. + * - for regular beacon node, it will keep 32 slots of attestations + * - for beacon node subscribing to all subnets, it will keep removing slots until it meets the above conditions. + * This ensures we have some SingleAttesations for block production while it does not occupy a lot of memory. + */ + prune(clockSlot: Slot): void { + // this value is for post-deneb + const slotsToRetain = computeSlotsSinceEpochStart(clockSlot, computeEpochAtSlot(clockSlot) - 1); + + pruneBySlot(this.committeeByIndexByRootBySlot, clockSlot, slotsToRetain); + this.lowestPermissibleSlot = Math.max(clockSlot - slotsToRetain + 1, 0); + + const attestationCountBySlot: number[] = []; + for (let slot = this.lowestPermissibleSlot; slot <= clockSlot; slot++) { + attestationCountBySlot.push(this.getAttestationCountAtSlot(slot)); + } + + let totalAttestationCount = attestationCountBySlot.reduce((sum, count) => sum + count, 0); + // remove slots until we have less than MAX_ATTESTATIONS_RETAINED + // this will never reached in normal network condition + let slot = this.lowestPermissibleSlot; + while (totalAttestationCount > MAX_ATTESTATIONS_RETAINED && slot <= clockSlot) { + const countAtSlot = attestationCountBySlot[slot - this.lowestPermissibleSlot]; + totalAttestationCount -= countAtSlot; + this.committeeByIndexByRootBySlot.delete(slot); + slot++; + } + } + + private onScrapeMetrics(metrics: Metrics): void { + const poolMetrics = metrics.opPool.singleAttestationPool; + const allSlots = Array.from(this.committeeByIndexByRootBySlot.keys()); + + // last item is current slot, we want the previous one, if available. + const previousSlot = allSlots.length > 1 ? (allSlots.at(-2) ?? null) : null; + + // always record the previous slot because the current slot may not be finished yet, we may receive more attestations + if (previousSlot !== null) { + const committeeByIndexByRoot = this.committeeByIndexByRootBySlot.get(previousSlot); + if (committeeByIndexByRoot != null) { + poolMetrics.attDataPerSlot.set(committeeByIndexByRoot.size); + + let minAttestations = Infinity; + let committeeCount = 0; + for (const committeeByIndex of committeeByIndexByRoot.values()) { + for (const committeeInfo of committeeByIndex.values()) { + const attestationCount = committeeInfo.attestations.size; + minAttestations = Math.min(minAttestations, attestationCount); + committeeCount += 1; + } + } + // expect some committees have so few attestations and it's not included in AggreatedAttestationPool + // we could include these attestations for block production + poolMetrics.minAttestationsPerCommittee.set(minAttestations); + poolMetrics.committeesPerSlot.set(committeeCount); + } + + poolMetrics.attestationsPerSlot.set(this.getAttestationCountAtSlot(previousSlot)); + } + + poolMetrics.size.set(this.getAttestationCount()); + poolMetrics.slotCount.set(this.committeeByIndexByRootBySlot.size); + } + + /** Returns current count of SingleAttestations */ + getAttestationCount(): number { + let attestationCount = 0; + for (const slot of this.committeeByIndexByRootBySlot.keys()) { + attestationCount += this.getAttestationCountAtSlot(slot); + } + return attestationCount; + } + + /** + * Returns the count of SingleAttestations for a specific slot. + */ + private getAttestationCountAtSlot(slot: Slot): number { + const committeeByIndexByRoot = this.committeeByIndexByRootBySlot.get(slot); + if (committeeByIndexByRoot == null) { + return 0; + } + + let attestationCount = 0; + for (const committeeByIndex of committeeByIndexByRoot.values()) { + for (const committee of committeeByIndex.values()) { + attestationCount += committee.attestations.size; + } + } + return attestationCount; + } +} + +/** + * Aggregate SingleAttestation of the same committee and attestation data into an aggregated attestation. + */ +function aggregateAttestations( + sameComitteeAttestations: Map, + committeeIndex: CommitteeIndex, + committeeSize: number +): Attestation { + if (sameComitteeAttestations.size === 0) { + throw new Error("Cannot aggregate empty attestations"); + } + + const aggregationBits = BitArray.fromBitLen(committeeSize); + const signatures: Signature[] = []; + let attestationData: phase0.AttestationData | undefined = undefined; + for (const [committeeValidatorIndex, singleAttestation] of sameComitteeAttestations.entries()) { + aggregationBits.set(committeeValidatorIndex, true); + signatures.push(signatureFromBytesNoCheck(singleAttestation.signature)); + if (!attestationData) { + // We assume all attestations have the same data + attestationData = singleAttestation.data; + } + } + const aggregatedSignature = aggregateSignatures(signatures); + + if (attestationData === undefined) { + // should not happen because we checked the size above + throw new Error("Cannot aggregate attestations without data"); + } + + return { + aggregationBits, + data: attestationData, + signature: aggregatedSignature.toBytes(), + committeeBits: BitArray.fromSingleBit(MAX_COMMITTEES_PER_SLOT, committeeIndex), + }; +} diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 7aee93116425..30765a038399 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -47,6 +47,7 @@ import { import {fromGraffitiBytes} from "../../util/graffiti.js"; import type {BeaconChain} from "../chain.js"; import {CommonBlockBody} from "../interface.js"; +import {getAttestationsForBlock} from "../opPools/getAttestationsForBlock.js"; import {validateBlobsAndKzgCommitments} from "./validateBlobsAndKzgCommitments.js"; // Time to provide the EL to generate a payload from new payload id @@ -715,7 +716,7 @@ export async function produceCommonBlockBody( this.opPool.getSlashingsAndExits(currentState, blockType, this.metrics); const endAttestations = stepsMetrics?.startTimer(); - const attestations = this.aggregatedAttestationPool.getAttestationsForBlock(fork, this.forkChoice, currentState); + const attestations = getAttestationsForBlock.call(this, fork, currentState); endAttestations?.({ step: BlockProductionStep.attestations, }); diff --git a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts index 799fd7caf3d2..2d94405bad6f 100644 --- a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts +++ b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts @@ -4,7 +4,7 @@ import { createAggregateSignatureSetFromComponents, isAggregatorFromCommitteeLength, } from "@lodestar/state-transition"; -import {IndexedAttestation, RootHex, SignedAggregateAndProof, electra, ssz} from "@lodestar/types"; +import {CommitteeIndex, IndexedAttestation, RootHex, SignedAggregateAndProof, electra, ssz} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {AttestationError, AttestationErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../index.js"; @@ -23,6 +23,7 @@ export type AggregateAndProofValidationResult = { indexedAttestation: IndexedAttestation; committeeIndices: Uint32Array; attDataRootHex: RootHex; + committeeIndex: CommitteeIndex; }; export async function validateApiAggregateAndProof( @@ -71,11 +72,11 @@ async function validateAggregateAndProof( const attData = aggregate.data; const attSlot = attData.slot; - let attIndex: number | null; + let committeeIndex: number | null; if (ForkSeq[fork] >= ForkSeq.electra) { - attIndex = (aggregate as electra.Attestation).committeeBits.getSingleTrueBit(); + committeeIndex = (aggregate as electra.Attestation).committeeBits.getSingleTrueBit(); // [REJECT] len(committee_indices) == 1, where committee_indices = get_committee_indices(aggregate) - if (attIndex === null) { + if (committeeIndex === null) { throw new AttestationError(GossipAction.REJECT, {code: AttestationErrorCode.NOT_EXACTLY_ONE_COMMITTEE_BIT_SET}); } // [REJECT] aggregate.data.index == 0 @@ -83,11 +84,11 @@ async function validateAggregateAndProof( throw new AttestationError(GossipAction.REJECT, {code: AttestationErrorCode.NON_ZERO_ATTESTATION_DATA_INDEX}); } } else { - attIndex = attData.index; + committeeIndex = attData.index; } const seenAttDataKey = serializedData ? getSeenAttDataKeyFromSignedAggregateAndProof(fork, serializedData) : null; - const cachedAttData = seenAttDataKey ? chain.seenAttestationDatas.get(attSlot, attIndex, seenAttDataKey) : null; + const cachedAttData = seenAttDataKey ? chain.seenAttestationDatas.get(attSlot, committeeIndex, seenAttDataKey) : null; const attEpoch = computeEpochAtSlot(attSlot); const attTarget = attData.target; @@ -136,7 +137,7 @@ async function validateAggregateAndProof( : toRootHex(ssz.phase0.AttestationData.hashTreeRoot(attData)); if ( !skipValidationKnownAttesters && - chain.seenAggregatedAttestations.isKnown(targetEpoch, attIndex, attDataRootHex, aggregationBits) + chain.seenAggregatedAttestations.isKnown(targetEpoch, committeeIndex, attDataRootHex, aggregationBits) ) { throw new AttestationError(GossipAction.IGNORE, { code: AttestationErrorCode.ATTESTERS_ALREADY_KNOWN, @@ -177,7 +178,7 @@ async function validateAggregateAndProof( // -- i.e. data.index < get_committee_count_per_slot(state, data.target.epoch) const committeeIndices = cachedAttData ? cachedAttData.committeeValidatorIndices - : getCommitteeIndices(shuffling, attSlot, attIndex); + : getCommitteeIndices(shuffling, attSlot, committeeIndex); // [REJECT] The number of aggregation bits matches the committee size // -- i.e. `len(aggregation_bits) == len(get_beacon_committee(state, aggregate.data.slot, index))`. @@ -246,13 +247,14 @@ async function validateAggregateAndProof( } chain.seenAggregators.add(targetEpoch, aggregatorIndex); - chain.seenAggregatedAttestations.add( + chain.addSeenAgregatedAttestation( + attSlot, targetEpoch, - attIndex, + committeeIndex, attDataRootHex, {aggregationBits, trueBitCount: attestingIndices.length}, false ); - return {indexedAttestation, committeeIndices, attDataRootHex}; + return {indexedAttestation, committeeIndex, committeeIndices, attDataRootHex}; } diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 74924744acb2..a10955b4d857 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -866,6 +866,11 @@ export function createLodestarMetrics( name: "lodestar_oppool_aggregated_attestation_pool_packed_attestations_total_consolidations_total", help: "Total number of consolidations before truncate", }), + packedAttestations: register.histogram({ + name: "lodestar_oppool_aggregated_attestation_pool_packed_attestations_total", + help: "Total number of packed attestations produced (after truncate consolidations)", + buckets: [2, 3, 4, 6, 8], + }), emptyAttestationData: register.gauge({ name: "lodestar_oppool_aggregated_attestation_pool_packed_attestations_empty_attestation_data_total", help: "Total number of attestation data with no group when producing packed attestation", @@ -875,9 +880,116 @@ export function createLodestarMetrics( help: "Total number of invalid attestation data when producing packed attestation", labelNames: ["reason"], }), - seenCommittees: register.gauge({ + seenCommittees: register.gauge<{inclusionDistance: number}>({ name: "lodestar_oppool_aggregated_attestation_pool_packed_attestations_seen_committees_total", help: "Total number of committees for which all members are seen when producing packed attestations", + labelNames: ["inclusionDistance"], + }), + }, + }, + singleAttestationPool: { + size: register.gauge({ + name: "lodestar_oppool_single_attestation_pool_size", + help: "Current size of the SingleAttestationPool = total attestations stored", + }), + slotCount: register.gauge({ + name: "lodestar_oppool_single_attestation_pool_slot_count", + help: "Current number of slots in the SingleAttestationPool", + }), + attDataPerSlot: register.gauge({ + name: "lodestar_oppool_single_attestation_pool_attestation_data_per_slot_total", + help: "Total number of attestation data per slot in SingleAttestationPool", + }), + committeesPerSlot: register.gauge({ + name: "lodestar_oppool_single_attestation_pool_committees_per_slot_total", + help: "Total number of committees per slot in SingleAttestationPool", + }), + attestationsPerSlot: register.gauge({ + name: "lodestar_oppool_single_attestation_pool_attestations_per_slot_total", + help: "Total number of attestations per slot in SingleAttestationPool", + }), + minAttestationsPerCommittee: register.gauge({ + name: "lodestar_oppool_single_attestation_pool_min_attestations_per_committee", + help: "Min number of attestations per committee in SingleAttestationPool", + }), + gossipInsertOutcome: register.counter<{insertOutcome: InsertOutcome}>({ + name: "lodestar_oppool_single_attestation_pool_gossip_insert_outcome_total", + help: "Total number of InsertOutcome as a result of adding a single attestation from gossip to the pool", + labelNames: ["insertOutcome"], + }), + apiInsertOutcome: register.counter<{insertOutcome: InsertOutcome}>({ + name: "lodestar_oppool_single_attestation_pool_api_insert_outcome_total", + help: "Total number of InsertOutcome as a result of adding a single attestation from api to the pool", + labelNames: ["insertOutcome"], + }), + packedAttestations: { + committeeCount: register.gauge<{index: number}>({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_committee_count", + help: "Total number of committees in packed attestation ${index}", + labelNames: ["index"], + }), + totalAttesters: register.gauge<{index: number}>({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_attesters_total", + help: "Total number of attesters in packed attestation ${index}", + labelNames: ["index"], + }), + nonParticipation: register.gauge<{index: number}>({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_non_participation_total", + help: "Total number of not seen attesters in packed attestation ${index}", + labelNames: ["index"], + }), + newSeenAttesters: register.gauge<{index: number}>({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_new_seen_attesters_total", + help: "Total number of new seen attesters in packed attestation ${index}", + labelNames: ["index"], + }), + totalEffectiveBalance: register.gauge<{index: number}>({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_effective_balance_total", + help: "Total effective balance of new seen attesters in packed attestation ${index}", + labelNames: ["index"], + }), + inclusionDistance: register.gauge<{index: number}>({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_inclusion_distance_total", + help: "How far the packed attestation ${index} slot is from the block slot", + labelNames: ["index"], + }), + scannedSlots: register.gauge<{reason: ScannedSlotsTerminationReason}>({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_scanned_slots_total", + help: "Total number of scanned slots to produce packed attestations", + labelNames: ["reason"], + }), + returnedAttestations: register.gauge<{inclusionDistance: number}>({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_returned_attestations_total", + help: "Total number of returned attestations per scanned slot to produce packed attestations", + labelNames: ["inclusionDistance"], + }), + poolSlots: register.gauge({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_pool_slots_total", + help: "Total number of slots in pool when producing packed attestations", + }), + totalConsolidations: register.gauge({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_total_consolidations_total", + help: "Total number of consolidations before truncate", + }), + emptyCommittee: register.gauge<{inclusionDistance: number}>({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_empty_committee_total", + help: "No committee for an attestation data root of slot", + labelNames: ["inclusionDistance"], + }), + emptyAttestation: register.gauge<{inclusionDistance: number}>({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_empty_attestation_total", + help: "No attestation for a committee", + labelNames: ["inclusionDistance"], + }), + invalidAttestationData: register.gauge<{reason: InvalidAttestationData}>({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_invalid_attestation_data_total", + help: "Total number of invalid attestation data when producing packed attestation", + labelNames: ["reason"], + }), + seenCommittees: register.gauge<{inclusionDistance: number}>({ + name: "lodestar_oppool_single_attestation_pool_packed_attestations_seen_committees_total", + help: "Total number of committees for which all members are seen when producing packed attestations", + labelNames: ["inclusionDistance"], }), }, }, diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index b3e6947e66f2..f469fdf9ca25 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -10,6 +10,7 @@ import { SubnetID, UintNum64, deneb, + isElectraSingleAttestation, ssz, sszTypesFor, } from "@lodestar/types"; @@ -690,6 +691,17 @@ function getBatchHandlers(modules: ValidatorFnsModules, options: GossipHandlerOp ); metrics?.opPool.attestationPool.gossipInsertOutcome.inc({insertOutcome}); } + + if (isElectraSingleAttestation(attestation)) { + const insertOutcome = chain.singleAttestationPool.add( + committeeIndex, + attestation, + attDataRootHex, + committeeValidatorIndex, + committeeSize + ); + metrics?.opPool.singleAttestationPool.gossipInsertOutcome.inc({insertOutcome}); + } } catch (e) { logger.error("Error adding unaggregated attestation to pool", {subnet}, e as Error); } diff --git a/packages/beacon-node/test/mocks/mockedBeaconChain.ts b/packages/beacon-node/test/mocks/mockedBeaconChain.ts index 5cf2f3a44ff5..80b842d38e29 100644 --- a/packages/beacon-node/test/mocks/mockedBeaconChain.ts +++ b/packages/beacon-node/test/mocks/mockedBeaconChain.ts @@ -8,7 +8,12 @@ import {BeaconProposerCache} from "../../src/chain/beaconProposerCache.js"; import {BeaconChain} from "../../src/chain/chain.js"; import {ChainEventEmitter} from "../../src/chain/emitter.js"; import {LightClientServer} from "../../src/chain/lightClient/index.js"; -import {AggregatedAttestationPool, OpPool, SyncContributionAndProofPool} from "../../src/chain/opPools/index.js"; +import { + AggregatedAttestationPool, + OpPool, + SingleAttestationPool, + SyncContributionAndProofPool, +} from "../../src/chain/opPools/index.js"; import {QueuedStateRegenerator} from "../../src/chain/regen/index.js"; import {ShufflingCache} from "../../src/chain/shufflingCache.js"; import {Eth1ForBlockProduction} from "../../src/eth1/index.js"; @@ -27,6 +32,7 @@ export type MockedBeaconChain = Mocked & { eth1: Mocked; opPool: Mocked; aggregatedAttestationPool: Mocked; + singleAttestationPool: Mocked; syncContributionAndProofPool: Mocked; beaconProposerCache: Mocked; shufflingCache: Mocked; @@ -92,7 +98,17 @@ vi.mock("../../src/chain/opPools/index.js", async (importActual) => { const AggregatedAttestationPool = vi.fn().mockImplementation(() => { return { + getAttestationsForBlockElectraBySlot: vi.fn(), + getStoredSlots: vi.fn(), getAttestationsForBlock: vi.fn(), + getAttestationsForBlockPreElectra: vi.fn(), + }; + }); + + const SingleAttestationPool = vi.fn().mockImplementation(() => { + return { + getAttestationsForBlockElectraBySlot: vi.fn(), + getStoredSlots: vi.fn(), }; }); @@ -106,6 +122,7 @@ vi.mock("../../src/chain/opPools/index.js", async (importActual) => { ...mod, OpPool, AggregatedAttestationPool, + SingleAttestationPool, SyncContributionAndProofPool, }; }); @@ -138,6 +155,7 @@ vi.mock("../../src/chain/chain.js", async (importActual) => { eth1: new Eth1ForBlockProduction(), opPool: new OpPool(), aggregatedAttestationPool: new AggregatedAttestationPool(config), + singleAttestationPool: new SingleAttestationPool(), syncContributionAndProofPool: new SyncContributionAndProofPool(clock), // @ts-expect-error beaconProposerCache: new BeaconProposerCache(), diff --git a/packages/beacon-node/test/unit-minimal/chain/opPools/aggregatedAttestationPool.test.ts b/packages/beacon-node/test/unit-minimal/chain/opPools/aggregatedAttestationPool.test.ts index d1e7db8d304a..59c148adafc9 100644 --- a/packages/beacon-node/test/unit-minimal/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/unit-minimal/chain/opPools/aggregatedAttestationPool.test.ts @@ -18,7 +18,9 @@ import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest import { AggregatedAttestationPool, AttestationsConsolidation, + GetNotSeenValidatorsFn, MatchingDataAttestationGroup, + ValidateAttestationDataFn, aggregateConsolidation, aggregateInto, getNotSeenValidatorsFn, @@ -161,6 +163,219 @@ describe("AggregatedAttestationPool - Altair", () => { }); }); +describe("AggregatedAttestationPool - getAttestationsForBlockElectraBySlot", () => { + const electraForkEpoch = 2020; + const config = createChainForkConfig({ + ...defaultChainConfig, + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: electraForkEpoch, + }); + const committeeLength = 32; + const committeeIndices = [0, 1, 2, 3]; + const committees: Uint32Array[] = []; + const effectiveBalanceIncrements = new Uint16Array(committeeLength * committeeIndices.length).fill(32); + for (let i = 0; i < committeeIndices.length; i++) { + committees[i] = Uint32Array.from(linspace(i * committeeLength, (i + 1) * committeeLength - 1)); + } + const attestation = ssz.electra.Attestation.defaultValue(); + const currentEpoch = electraForkEpoch + 10; + const currentSlot = SLOTS_PER_EPOCH * currentEpoch; + // it will always include attestations for stateSlot - 1 which is currentSlot + // so we want attestation slot to be less than that to test epochParticipation + attestation.data.slot = currentSlot - 1; + attestation.data.index = 0; // Must be zero post-electra + attestation.data.target.epoch = currentEpoch; + attestation.signature = validSignature; + const attDataRootHex = toHexString(ssz.phase0.AttestationData.hashTreeRoot(attestation.data)); + + let pool: AggregatedAttestationPool; + + beforeEach(() => { + pool = new AggregatedAttestationPool(config); + }); + + const testCases: { + name: string; + // item i is for committee i, which contains array of attester indices that's not seen (seen by default) + notSeenInStateByCommittee: number[][]; + // item i is for committee i, each item is number[][] which is the indices of validators not seen by the committee + // each item i also decides how many attestations added to the pool for that committee + attParticipationByCommittee: number[][][]; + // expected returned committees for each consolidations + // item 0 is for returned consolidation 0, ... + consolidationCommittees: number[][]; + // expected Not Seen Committee Members for each consolidation, same size to returnedCommittees + // item 0 is for returned consolidation 0, ... + consolidationNotSeenCommitteeMembers: number[][][]; + notSeenCommitteeMembersByIndex: (number[] | null | undefined)[]; + }[] = [ + { + name: "Full participation", + notSeenInStateByCommittee: [ + [0, 1, 2, 3], + [0, 1, 2, 3], + [0, 1, 2, 3], + [0, 1, 2, 3], + ], + // each committee has exactly 1 full attestations + attParticipationByCommittee: [[[]], [[]], [[]], [[]]], + // 1 full packed attestation + consolidationCommittees: [[0, 1, 2, 3]], + consolidationNotSeenCommitteeMembers: [[[], [], [], []]], + notSeenCommitteeMembersByIndex: [[], [], [], []], + }, + { + name: "Full participation but all are seen in the state", + notSeenInStateByCommittee: [[], [], [], []], + // each committee has exactly 1 full attestations + attParticipationByCommittee: [[[]], [[]], [[]], [[]]], + // no packed attestation + consolidationCommittees: [], + consolidationNotSeenCommitteeMembers: [], + notSeenCommitteeMembersByIndex: [undefined, undefined, undefined, undefined], + }, + { + name: "Committee 1 and 2 has 2 versions of aggregationBits", + notSeenInStateByCommittee: [ + [0, 1, 2, 3], + [0, 1, 2, 3], + [0, 1, 2, 3], + [0, 1, 2, 3], + ], + // committee 1 has 2 attestations, one with no participation validator 0, one with no participation validator 1 + // committee 2 has 2 attestations, one with no participation validator 1, one with no participation validator 2 + // committee 0 and 3 has 1 attestation each, and all validators are seen + attParticipationByCommittee: [[[]], [[0], [1]], [[1], [2]], [[]]], + // 2nd packed attestation only has 2 committees: 1 and 2 + consolidationCommittees: [ + [0, 1, 2, 3], + [1, 2], + ], + consolidationNotSeenCommitteeMembers: [ + [[], [0], [1], []], + // committee 1: not seen committee validator index is 0 but it's seen by consolidaiton 1 + // committee 2: not seen committee validator index is 1 but it's seen by consolidation 1 + [[], []], + ], + notSeenCommitteeMembersByIndex: [[], [], [], []], + }, + { + // same to above but no-participation validators are all seen in the state so only 1 attestation is returned + name: "Committee 1 and 2 has 2 versions of aggregationBits - only 1 attestation is included", + notSeenInStateByCommittee: [ + [0, 1, 2, 3], + [2, 3], + [0, 1], + [0, 1, 2, 3], + ], + // committee 1 has 2 attestations, one with no participation validator 0, one with no participation validator 1 + // committee 2 has 2 attestations, one with no participation validator 1, one with no participation validator 2 + // committee 0 and 3 has 1 attestation each, and all validators are seen + attParticipationByCommittee: [[[]], [[0], [1]], [[1], [2]], [[]]], + consolidationCommittees: [[0, 1, 2, 3]], + consolidationNotSeenCommitteeMembers: [[[], [], [], []]], + notSeenCommitteeMembersByIndex: [[], [], [], []], + }, + { + name: "Only committee 1 has 2 versions of aggregationBits", + notSeenInStateByCommittee: [ + [0, 1, 2, 3], + [0, 1, 2, 3], + [0, 1, 2, 3], + [0, 1, 2, 3], + ], + // committee 1 has 2 attestations, one with no participation validator 0, one with no participation validator 1 + // other committees have 1 attestation each, and all validators are seen + attParticipationByCommittee: [[[]], [[0], [1]], [[]], [[]]], + // 2nd consolidation only has 1 committee + consolidationCommittees: [[0, 1, 2, 3], [1]], + consolidationNotSeenCommitteeMembers: [ + // consolidation 0 + [[], [0], [], []], + // consolidation 1, committee 1: not seen committee validator index is 0 but it's seen by consolidaiton 1 + [[], []], + ], + notSeenCommitteeMembersByIndex: [[], [], [], []], + }, + ]; + + for (const { + name, + notSeenInStateByCommittee, + attParticipationByCommittee, + consolidationCommittees, + consolidationNotSeenCommitteeMembers, + notSeenCommitteeMembersByIndex: expectedNotSeenCommitteeMembersByIndex, + } of testCases) { + it(name, () => { + const notSeenValidatorsFn: GetNotSeenValidatorsFn = (__, _, committeeIndex) => { + return new Set(notSeenInStateByCommittee[committeeIndex]); + }; + + // populate the pool + for (let i = 0; i < committeeIndices.length; i++) { + const committeeIndex = committeeIndices[i]; + const committeeBits = BitArray.fromSingleBit(MAX_COMMITTEES_PER_SLOT, committeeIndex); + // same committee, each is by attestation + const notSeenValidatorsByAttestationIndex = attParticipationByCommittee[i]; + for (const notSeenValidators of notSeenValidatorsByAttestationIndex) { + const aggregationBits = new BitArray(new Uint8Array(committeeLength / 8).fill(255), committeeLength); + for (const index of notSeenValidators) { + aggregationBits.set(index, false); + } + const attestationi: Attestation = { + ...attestation, + aggregationBits, + committeeBits, + }; + + pool.add(attestationi, attDataRootHex, aggregationBits.getTrueBitIndexes().length, committees[i]); + } + } + + // assume all attestation data is valid + const validateAttestationDataFn: ValidateAttestationDataFn = () => null; + + const {consolidations, notSeenCommitteeMembersByIndex} = pool.getAttestationsForBlockElectraBySlot( + attestation.data.slot, + ForkName.electra, + currentSlot, + effectiveBalanceIncrements, + notSeenValidatorsFn, + validateAttestationDataFn + ); + expect(consolidations.length).toBe(consolidationCommittees.length); + expect(consolidations.length).toBe(consolidationNotSeenCommitteeMembers.length); + + for (let consIndex = 0; consIndex < consolidations.length; consIndex++) { + const consolidation = consolidations[consIndex]; + expect(Array.from(consolidation.byCommittee.keys())).toStrictEqual(consolidationCommittees[consIndex]); + for (const [i, attestationNonParticipation] of Array.from(consolidation.byCommittee.values()).entries()) { + const notSeenCommitteeMembers = consolidationNotSeenCommitteeMembers[consIndex][i]; + expect(Array.from(attestationNonParticipation.notSeenCommitteeMembers)).toEqual(notSeenCommitteeMembers); + } + } + + for (const committeeIndex of committeeIndices) { + if (expectedNotSeenCommitteeMembersByIndex[committeeIndex] === null) { + expect(notSeenCommitteeMembersByIndex.get(committeeIndex)).toBeNull(); + } else if (expectedNotSeenCommitteeMembersByIndex[committeeIndex] === undefined) { + expect(notSeenCommitteeMembersByIndex.get(committeeIndex)).toBeUndefined(); + } else { + const notSeenCommitteeMembers = notSeenCommitteeMembersByIndex.get(committeeIndex); + if (notSeenCommitteeMembers == null) { + throw new Error(`notSeenCommitteeMembers for committee ${committeeIndex} is null`); + } + expect(Array.from(notSeenCommitteeMembers)).toEqual(expectedNotSeenCommitteeMembersByIndex[committeeIndex]); + } + } + }); + } +}); + describe("AggregatedAttestationPool - get packed attestations - Electra", () => { let pool: AggregatedAttestationPool; const fork = ForkName.electra; diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts index 4364486dabf7..0cefdfb89211 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts @@ -245,7 +245,7 @@ describe("api/validator - produceBlockV3", () => { modules.chain.recomputeForkChoiceHead.mockReturnValue(generateProtoBlock({slot: headSlot})); modules.chain["opPool"].getSlashingsAndExits.mockReturnValue([[], [], [], []]); - modules.chain["aggregatedAttestationPool"].getAttestationsForBlock.mockReturnValue([]); + modules.chain["aggregatedAttestationPool"].getAttestationsForBlockPreElectra.mockReturnValue([]); modules.chain["eth1"].getEth1DataAndDeposits.mockResolvedValue({ eth1Data: ssz.phase0.Eth1Data.defaultValue(), deposits: [], diff --git a/packages/beacon-node/test/unit/chain/opPools/getAttestationsForBlock.test.ts b/packages/beacon-node/test/unit/chain/opPools/getAttestationsForBlock.test.ts new file mode 100644 index 000000000000..c53f56192251 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/opPools/getAttestationsForBlock.test.ts @@ -0,0 +1,211 @@ +import {BitArray, fromHexString} from "@chainsafe/ssz"; +import {ForkName, ForkPostElectra} from "@lodestar/params"; +import {Attestation, CommitteeIndex, Slot, phase0, ssz} from "@lodestar/types"; +import {beforeEach, describe, expect, it} from "vitest"; +import { + AttestationNonParticipant, + AttestationsConsolidation, +} from "../../../../src/chain/opPools/aggregatedAttestationPool.js"; +import {getAttestationsForBlock} from "../../../../src/chain/opPools/getAttestationsForBlock.js"; +import {MockedBeaconChain, getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js"; +import {generateCachedElectraState} from "../../../utils/state.js"; + +describe("getAttestationsForBlock", () => { + let chain: MockedBeaconChain; + const state = generateCachedElectraState({slot: 20252025}); + + beforeEach(() => { + chain = getMockedBeaconChain(); + }); + + it("should produce packed attestations from both pools", () => { + const storedSlots = [ + state.slot - 1, + state.slot - 2, + state.slot - 3, + state.slot - 4, + state.slot - 5, + state.slot - 6, + ]; + chain.aggregatedAttestationPool.getStoredSlots.mockReturnValue(storedSlots); + + const aggAttestationPoolResult = { + consolidations: storedSlots.map((slot) => generateAAConsolidation(slot)), + notSeenCommitteeMembersByIndex: new Map>(), + }; + chain.aggregatedAttestationPool.getAttestationsForBlockElectraBySlot.mockReturnValue(aggAttestationPoolResult); + + // only the most recent 3 slots has missing committee members' attestations + chain.singleAttestationPool.getStoredSlots.mockReturnValue(new Set(storedSlots.slice(0, 3))); + const singleAttestationPoolResult = storedSlots.slice(0, 3).map((slot) => generateSAConsolidation(slot)); + chain.singleAttestationPool.getAttestationsForBlockElectraBySlot.mockReturnValue(singleAttestationPoolResult); + + const packedAttestations = getAttestationsForBlock.call(chain, ForkName.electra, state); + expect(packedAttestations.length).toBe(8); + + // order of attestations: 6 from aggregated attestation pool, 2 from single attestation pool + // the last 1 attestation from SingleAttestationPool of state.slot - 3 is not included + + // confirm committeeBits are full for all packed attestations from aggregated attestation pool + for (const [i, packedAttestation] of packedAttestations.slice(0, storedSlots.length).entries()) { + expect((packedAttestation as Attestation).committeeBits.getTrueBitIndexes().length).toBe( + committeeCount + ); + // 1 committee has 1 non-participant + expect(packedAttestation.aggregationBits.getTrueBitIndexes().length).toBe( + committeeCount * (committeeSize - nonParticipationPerCommittee) + ); + expect(packedAttestation.data.slot).toBe(storedSlots[i]); + } + + // aggregatedAttestationPool returns 3 attestations for slot state.slot - 1, state.slot - 2, state.slot - 3 + // but only the first 2 are included due to the limit of 8 packed attestations + for (const [i, packedAttestation] of packedAttestations.slice(packedAttestations.length - 2).entries()) { + expect((packedAttestation as Attestation).committeeBits.getTrueBitIndexes().length).toBe( + committeeCount + ); + // 1 committee has 1 non-participant + expect(packedAttestation.aggregationBits.getTrueBitIndexes().length).toBe( + committeeCount * nonParticipationPerCommittee + ); + // attestations are ordered by score, however all attestations have the same total effective balance + // hence only slot distance matters + expect(packedAttestation.data.slot).toBe(storedSlots[i]); + } + }); +}); + +/** Valid signature of random data to prevent BLS errors */ +const validSignature = fromHexString( + "0xb2afb700f6c561ce5e1b4fedaec9d7c06b822d38c720cf588adfda748860a940adf51634b6788f298c552de40183b5a203b2bbe8b7dd147f0bb5bc97080a12efbb631c8888cb31a99cc4706eb3711865b8ea818c10126e4d818b542e9dbf9ae8" +); + +const committeeCount = 64; +const committeeSize = 32; +const nonParticipationPerCommittee = 1; // assuming 1 non-participant per committee for simplicity + +/** + * generate AttestationsConsolidation for aggregated attestation pool + */ +function generateAAConsolidation(slot: Slot): AttestationsConsolidation { + const byCommittee = generateAANonParticipationByCommittee(slot); + return { + byCommittee, + attData: generateAttestationData(slot), + totalNewSeenEffectiveBalance: 32 * (committeeSize - nonParticipationPerCommittee) * committeeCount, + newSeenAttesters: (committeeSize - nonParticipationPerCommittee) * committeeCount, + notSeenAttesters: nonParticipationPerCommittee * committeeCount, + totalAttesters: committeeSize * committeeCount, + }; +} + +/** + * generate AttestationsConsolidation for SingleAttestationPool + */ +function generateSAConsolidation(slot: Slot): AttestationsConsolidation { + const byCommittee = generateSANonParticipationByCommittee(slot); + return { + byCommittee, + attData: generateAttestationData(slot), + totalNewSeenEffectiveBalance: 32 * nonParticipationPerCommittee * committeeCount, + newSeenAttesters: nonParticipationPerCommittee * committeeCount, + notSeenAttesters: 0, // all committee members are seen + totalAttesters: committeeSize * committeeCount, + }; +} + +/** + * generate a map of AttestationNonParticipant for each committee in the aggregated attestation pool + */ +function generateAANonParticipationByCommittee(slot: Slot): Map { + const result = new Map(); + for (let committeeIndex = 0; committeeIndex < committeeCount; committeeIndex++) { + result.set(committeeIndex, generateAAAttestationNonParticipant(slot, committeeIndex)); + } + return result; +} + +/** + * generate a map of AttestationNonParticipant for each committee in the SingleAttestationPool + */ +function generateSANonParticipationByCommittee(slot: Slot): Map { + const result = new Map(); + for (let committeeIndex = 0; committeeIndex < committeeCount; committeeIndex++) { + result.set(committeeIndex, generateSAAttestationNonParticipant(slot, committeeIndex)); + } + return result; +} + +// generate AttestationNonParticipant for aggregated attestation pool +function generateAAAttestationNonParticipant(slot: Slot, committeeIndex: CommitteeIndex): AttestationNonParticipant { + return { + attestation: generateAAAttestation(slot, committeeIndex), + newSeenEffectiveBalance: 32 * (committeeSize - nonParticipationPerCommittee), + newSeenAttesters: committeeSize - nonParticipationPerCommittee, + // the last committee member is not seen + notSeenCommitteeMembers: new Set([committeeSize - 1]), + }; +} + +/** + * generate AttestationNonParticipant for SingleAttestationPool + */ +function generateSAAttestationNonParticipant(slot: Slot, committeeIndex: CommitteeIndex): AttestationNonParticipant { + return { + attestation: generateSAAttestation(slot, committeeIndex), + newSeenEffectiveBalance: 32 * nonParticipationPerCommittee, + newSeenAttesters: nonParticipationPerCommittee, + notSeenCommitteeMembers: new Set(), + }; +} + +/** + * generate attestations for aggregated attestation pool + * assume last ${nonParticipationPerCommittee} committee members are not seen + */ +function generateAAAttestation(slot: Slot, committeeIndex: CommitteeIndex): Attestation { + const committeeBits = ssz.electra.CommitteeBits.defaultValue(); + committeeBits.set(committeeIndex, true); + const aggregationBits = BitArray.fromBitLen(committeeSize); + for (let i = 0; i < committeeSize - nonParticipationPerCommittee; i++) { + aggregationBits.set(i, true); + } + + return { + aggregationBits, + data: generateAttestationData(slot), + signature: validSignature, + committeeBits, + }; +} + +/** + * generate attestations for SingleAttestationPool + * assuming the last ${nonParticipationPerCommittee} committee members are seen + */ +function generateSAAttestation(slot: Slot, committeeIndex: CommitteeIndex): Attestation { + const committeeBits = ssz.electra.CommitteeBits.defaultValue(); + committeeBits.set(committeeIndex, true); + const aggregationBits = BitArray.fromBitLen(committeeSize); + for (let i = committeeSize - nonParticipationPerCommittee; i < committeeSize; i++) { + aggregationBits.set(i, true); + } + return { + aggregationBits, + data: generateAttestationData(slot), + signature: validSignature, + committeeBits, + }; +} + +function generateAttestationData(slot: Slot): phase0.AttestationData { + const sourceEpoch = Math.max(0, Math.floor(slot / 32) - 1); + const targetEpoch = Math.floor(slot / 32); + return { + slot: slot, + index: 0, + beaconBlockRoot: Buffer.alloc(32), + source: {epoch: sourceEpoch, root: Buffer.alloc(32)}, + target: {epoch: targetEpoch, root: Buffer.alloc(32)}, + }; +} diff --git a/packages/beacon-node/test/unit/chain/opPools/singleAttestationPool.test.ts b/packages/beacon-node/test/unit/chain/opPools/singleAttestationPool.test.ts new file mode 100644 index 000000000000..76f675679e70 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/opPools/singleAttestationPool.test.ts @@ -0,0 +1,271 @@ +import {SecretKey} from "@chainsafe/blst"; +import {BitArray} from "@chainsafe/ssz"; +import {ForkPostElectra} from "@lodestar/params"; +import {EffectiveBalanceIncrements} from "@lodestar/state-transition"; +import {CommitteeIndex, Epoch, SingleAttestation, Slot, phase0, ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; +import {beforeEach, describe, expect, it} from "vitest"; +import { + CommitteeValidatorIndex, + GetNotSeenValidatorsFn, + InvalidAttestationData, + ValidateAttestationDataFn, +} from "../../../../src/chain/opPools/aggregatedAttestationPool.js"; +import {SingleAttestationPool} from "../../../../src/chain/opPools/singleAttestationPool.js"; + +describe("SingleAttestationPool - stored slots", () => { + const pool = new SingleAttestationPool(null); + beforeEach(() => { + for (let slot = 0; slot < 96; slot++) { + const attestationData = generateAttestationData(slot); + const dataRoot = ssz.phase0.AttestationData.hashTreeRoot(attestationData); + const attestation: SingleAttestation = { + committeeIndex: 0, + attesterIndex: 0, + data: attestationData, + signature: Buffer.alloc(96, 0), + }; + pool.add(attestation.committeeIndex, attestation, toRootHex(dataRoot), 0, 512); + } + }); + + it("epoch boundary", () => { + pool.prune(96); + // pool should store 32 slots + const storedSlots = Array.from(pool.getStoredSlots()).sort((a, b) => a - b); + expect(storedSlots.length).toBe(32); + // store slot from 64 to 95 + for (const [i, slot] of storedSlots.entries()) { + expect(slot).toBe(64 + i); + } + }); + + it("last slot of epoch", () => { + for (let slot = 96; slot < 127; slot++) { + const attestationData = generateAttestationData(slot); + const dataRoot = ssz.phase0.AttestationData.hashTreeRoot(attestationData); + const attestation: SingleAttestation = { + committeeIndex: 0, + attesterIndex: 0, + data: attestationData, + signature: Buffer.alloc(96, 0), + }; + pool.add(attestation.committeeIndex, attestation, toRootHex(dataRoot), 0, 512); + } + + pool.prune(127); + + // pool should store 32 slots of prev epoch + 31 slots of current epoch + const storedSlots = Array.from(pool.getStoredSlots()).sort((a, b) => a - b); + expect(storedSlots.length).toBe(63); + + // store slot from 64 to 95 and 96 to 126 + for (const [i, slot] of storedSlots.entries()) { + expect(slot).toBe(64 + i); + } + }); +}); + +describe("SingleAttestationPool - seenAggregatedAttestation", () => { + const pool = new SingleAttestationPool(null); + const slot = 20252025; + const attestationData = generateAttestationData(slot); + const dataRoot = ssz.phase0.AttestationData.hashTreeRoot(attestationData); + const attestation: SingleAttestation = { + committeeIndex: 0, + attesterIndex: 0, + data: attestationData, + signature: Buffer.alloc(96, 0), + }; + const seenComitteeValidatorIndex = 3; + const committeeIndex = 63; + pool.add(committeeIndex, attestation, toRootHex(dataRoot), seenComitteeValidatorIndex, 512); + const notSeenCommitteeValidatorIndex = 4; + pool.add(committeeIndex, attestation, toRootHex(dataRoot), notSeenCommitteeValidatorIndex, 512); + + expect(pool.getAttestationCount()).toBe(2); + + const aggregationBits = BitArray.fromSingleBit(512, seenComitteeValidatorIndex); + pool.seenAggregatedAttestation(slot, toRootHex(dataRoot), committeeIndex, aggregationBits); + + // the attestation with seenComitteeValidatorIndex should be removed + expect(pool.getAttestationCount()).toBe(1); + + // it's a little bit tricky because even we add 2 different attestations with different slots, still cannot confirm the stored slots + // since the method getStoredSlots() returns the slots that are stored in the pool, not the slots of the attestations +}); + +describe("SingleAttestationPool - getAttestationsForBlockElectraBySlot", () => { + const pool = new SingleAttestationPool(null); + const slot = 20252025; + const stateSlot = slot + 1; + const committeeIndex = 1; + const committeeValidatorIndex = 4; + + beforeEach(() => { + const attestationData = generateAttestationData(slot); + const dataRoot = ssz.phase0.AttestationData.hashTreeRoot(attestationData); + const sk = SecretKey.fromBytes(Buffer.alloc(32, 1)); + const attestation: SingleAttestation = { + committeeIndex, + attesterIndex: 0, + data: attestationData, + signature: sk.sign(ssz.phase0.AttestationData.hashTreeRoot(attestationData)).toBytes(), + }; + pool.add(committeeIndex, attestation, toRootHex(dataRoot), committeeValidatorIndex, 512); + }); + + it("invalid attestation data", () => { + const notSeenCommitteeMembersByIndex = new Map | null>(); + const effectiveBalanceIncrements: EffectiveBalanceIncrements = Uint16Array.from([32]); + const notSeenValidatorsFn: GetNotSeenValidatorsFn = () => new Set(); + // attestation data is invalid + const validateAttDataFn: ValidateAttestationDataFn = () => InvalidAttestationData.CannotGetShufflingDependentRoot; + const consolidations = pool.getAttestationsForBlockElectraBySlot( + slot, + stateSlot, + notSeenCommitteeMembersByIndex, + effectiveBalanceIncrements, + notSeenValidatorsFn, + validateAttDataFn + ); + expect(consolidations.length).toBe(0); + }); + + it("attestation is seen by notSeenCommitteeMembersByIndex", () => { + const notSeenCommitteeMembersByIndex = new Map | null>(); + // this validator is seen after getting through the aggregated attestation pool + notSeenCommitteeMembersByIndex.set( + committeeIndex, + new Set([committeeValidatorIndex + 1, committeeValidatorIndex + 2]) + ); + const effectiveBalanceIncrements: EffectiveBalanceIncrements = Uint16Array.from([32]); + // this validator is not seen in the state + const notSeenValidatorsFn: GetNotSeenValidatorsFn = () => new Set(); + // attestation data is valid + const validateAttDataFn: ValidateAttestationDataFn = () => null; + const consolidations = pool.getAttestationsForBlockElectraBySlot( + slot, + stateSlot, + notSeenCommitteeMembersByIndex, + effectiveBalanceIncrements, + notSeenValidatorsFn, + validateAttDataFn + ); + expect(consolidations.length).toBe(0); + }); + + it("attestation is seen by notSeenValidatorsFn", () => { + const notSeenCommitteeMembersByIndex = new Map | null>(); + // this validator is not check by the aggregated attestation pool, because maybe 2 aggregated attestation pool does not have this att data + const effectiveBalanceIncrements: EffectiveBalanceIncrements = Uint16Array.from([32]); + // this validator is seen in the state + const notSeenValidatorsFn: GetNotSeenValidatorsFn = () => new Set(); + // attestation data is valid + const validateAttDataFn: ValidateAttestationDataFn = () => null; + const consolidations = pool.getAttestationsForBlockElectraBySlot( + slot, + stateSlot, + notSeenCommitteeMembersByIndex, + effectiveBalanceIncrements, + notSeenValidatorsFn, + validateAttDataFn + ); + expect(consolidations.length).toBe(0); + }); + + it("attestation is included in the block, validator is not seen in notSeenCommitteeMembersByIndex", () => { + const notSeenCommitteeMembersByIndex = new Map | null>(); + notSeenCommitteeMembersByIndex.set(committeeIndex, new Set([committeeValidatorIndex])); + const effectiveBalanceIncrements: EffectiveBalanceIncrements = Uint16Array.from([32]); + // this function is not called validator is not seen in notSeenCommitteeMembersByIndex + const notSeenValidatorsFn: GetNotSeenValidatorsFn = () => new Set(); + // attestation data is valid + const validateAttDataFn: ValidateAttestationDataFn = () => null; + const consolidations = pool.getAttestationsForBlockElectraBySlot( + slot, + stateSlot, + notSeenCommitteeMembersByIndex, + effectiveBalanceIncrements, + notSeenValidatorsFn, + validateAttDataFn + ); + expect(consolidations.length).toBe(1); + }); + + it("attestation is included in the block, validator is not seen in BeaconState", () => { + const notSeenCommitteeMembersByIndex = new Map | null>(); + // notSeenCommitteeMembersByIndex has no info of committeeValidatorIndex + const effectiveBalanceIncrements: EffectiveBalanceIncrements = Uint16Array.from([32]); + // but validator is not seen in the state + const notSeenValidatorsFn: GetNotSeenValidatorsFn = () => new Set([committeeValidatorIndex]); + // attestation data is valid + const validateAttDataFn: ValidateAttestationDataFn = () => null; + const consolidations = pool.getAttestationsForBlockElectraBySlot( + slot, + stateSlot, + notSeenCommitteeMembersByIndex, + effectiveBalanceIncrements, + notSeenValidatorsFn, + validateAttDataFn + ); + expect(consolidations.length).toBe(1); + }); + + it("attestation is included in the block, aggregated into single consolidation", () => { + const attestationData = generateAttestationData(slot); + const dataRoot = ssz.phase0.AttestationData.hashTreeRoot(attestationData); + const sk2 = SecretKey.fromBytes(Buffer.alloc(32, 2)); + const committeeIndex2 = committeeIndex + 1; + const committeeValidatorIndex2 = committeeValidatorIndex + 1; + + const attestation: SingleAttestation = { + committeeIndex: committeeIndex2, + attesterIndex: 1, + data: attestationData, + signature: sk2.sign(ssz.phase0.AttestationData.hashTreeRoot(attestationData)).toBytes(), + }; + pool.add(committeeIndex2, attestation, toRootHex(dataRoot), committeeValidatorIndex2, 512); + + const notSeenCommitteeMembersByIndex = new Map | null>(); + // notSeenCommitteeMembersByIndex has no info of committeeValidatorIndex + const effectiveBalanceIncrements: EffectiveBalanceIncrements = Uint16Array.from([32, 2048]); + // but validator is not seen in the state + const notSeenValidatorsFn: GetNotSeenValidatorsFn = (__: Epoch, _: Slot, ci: CommitteeIndex) => { + return ci === committeeIndex + ? new Set([committeeValidatorIndex]) + : new Set([committeeValidatorIndex2, 100, 101, 102]); + }; + // attestation data is valid + const validateAttDataFn: ValidateAttestationDataFn = () => null; + const consolidations = pool.getAttestationsForBlockElectraBySlot( + slot, + stateSlot, + notSeenCommitteeMembersByIndex, + effectiveBalanceIncrements, + notSeenValidatorsFn, + validateAttDataFn + ); + expect(consolidations.length).toBe(1); + + const sameAttDataCon = consolidations[0]; + // 2 attesations are consolidated, same slot, different committee index + expect(Array.from(sameAttDataCon.byCommittee.keys())).toEqual([committeeIndex, committeeIndex2]); + expect(sameAttDataCon.totalNewSeenEffectiveBalance).toBe(2048 + 32); + expect(sameAttDataCon.newSeenAttesters).toBe(2); + expect(sameAttDataCon.notSeenAttesters).toEqual(3); + expect(sameAttDataCon.totalAttesters).toEqual(512 + 512); + }); +}); + +function generateAttestationData(slot: Slot): phase0.AttestationData { + const sourceEpoch = Math.max(0, Math.floor(slot / 32) - 1); + const targetEpoch = Math.floor(slot / 32); + return { + slot: slot, + index: 0, + beaconBlockRoot: Buffer.alloc(32), + source: {epoch: sourceEpoch, root: Buffer.alloc(32)}, + target: {epoch: targetEpoch, root: Buffer.alloc(32)}, + }; +} diff --git a/packages/beacon-node/test/utils/validationData/attestation.ts b/packages/beacon-node/test/utils/validationData/attestation.ts index 798303e62887..c547cfbbcd1c 100644 --- a/packages/beacon-node/test/utils/validationData/attestation.ts +++ b/packages/beacon-node/test/utils/validationData/attestation.ts @@ -159,6 +159,7 @@ export function getAttestationValidData(opts: AttestationValidDataOpts): { index2pubkey: state.epochCtx.index2pubkey, shufflingCache, opts: defaultChainOptions, + addSeenAgregatedAttestation: () => {}, } as Partial as IBeaconChain; return {chain, attestation, subnet, validatorIndex};