diff --git a/packages/api/src/beacon/routes/beacon/pool.ts b/packages/api/src/beacon/routes/beacon/pool.ts index 4d909c2aac7..9a65bd489a8 100644 --- a/packages/api/src/beacon/routes/beacon/pool.ts +++ b/packages/api/src/beacon/routes/beacon/pool.ts @@ -1,7 +1,16 @@ import {ValueOf} from "@chainsafe/ssz"; import {ChainForkConfig} from "@lodestar/config"; -import {isForkPostElectra} from "@lodestar/params"; -import {AttesterSlashing, CommitteeIndex, Slot, capella, electra, phase0, ssz} from "@lodestar/types"; +import {ForkPostElectra, ForkPreElectra, isForkPostElectra} from "@lodestar/params"; +import { + AttesterSlashing, + CommitteeIndex, + SingleAttestation, + Slot, + capella, + electra, + phase0, + ssz, +} from "@lodestar/types"; import { ArrayOf, EmptyArgs, @@ -20,6 +29,8 @@ import {MetaHeader, VersionCodec, VersionMeta} from "../../../utils/metadata.js" // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes +const SingleAttestationListTypePhase0 = ArrayOf(ssz.phase0.Attestation); +const SingleAttestationListTypeElectra = ArrayOf(ssz.electra.SingleAttestation); const AttestationListTypePhase0 = ArrayOf(ssz.phase0.Attestation); const AttestationListTypeElectra = ArrayOf(ssz.electra.Attestation); const AttesterSlashingListTypePhase0 = ArrayOf(ssz.phase0.AttesterSlashing); @@ -142,7 +153,7 @@ export type Endpoints = { */ submitPoolAttestations: Endpoint< "POST", - {signedAttestations: AttestationListPhase0}, + {signedAttestations: SingleAttestation[]}, {body: unknown}, EmptyResponseData, EmptyMeta @@ -158,7 +169,7 @@ export type Endpoints = { */ submitPoolAttestationsV2: Endpoint< "POST", - {signedAttestations: AttestationList}, + {signedAttestations: SingleAttestation[]}, {body: unknown; headers: {[MetaHeader.Version]: string}}, EmptyResponseData, EmptyMeta @@ -316,10 +327,10 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({body: AttestationListTypePhase0.toJson(signedAttestations)}), - parseReqJson: ({body}) => ({signedAttestations: AttestationListTypePhase0.fromJson(body)}), - writeReqSsz: ({signedAttestations}) => ({body: AttestationListTypePhase0.serialize(signedAttestations)}), - parseReqSsz: ({body}) => ({signedAttestations: AttestationListTypePhase0.deserialize(body)}), + writeReqJson: ({signedAttestations}) => ({body: SingleAttestationListTypePhase0.toJson(signedAttestations)}), + parseReqJson: ({body}) => ({signedAttestations: SingleAttestationListTypePhase0.fromJson(body)}), + writeReqSsz: ({signedAttestations}) => ({body: SingleAttestationListTypePhase0.serialize(signedAttestations)}), + parseReqSsz: ({body}) => ({signedAttestations: SingleAttestationListTypePhase0.deserialize(body)}), schema: { body: Schema.ObjectArray, }, @@ -334,8 +345,8 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions[]) + : SingleAttestationListTypePhase0.toJson(signedAttestations as SingleAttestation[]), headers: {[MetaHeader.Version]: fork}, }; }, @@ -343,16 +354,16 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { const fork = config.getForkName(signedAttestations[0]?.data.slot ?? 0); return { body: isForkPostElectra(fork) - ? AttestationListTypeElectra.serialize(signedAttestations as AttestationListElectra) - : AttestationListTypePhase0.serialize(signedAttestations as AttestationListPhase0), + ? SingleAttestationListTypeElectra.serialize(signedAttestations as SingleAttestation[]) + : SingleAttestationListTypePhase0.serialize(signedAttestations as SingleAttestation[]), headers: {[MetaHeader.Version]: fork}, }; }, @@ -360,8 +371,8 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions + ); + } else { + chain.emitter.emit(routes.events.EventType.attestation, attestation as SingleAttestation); + chain.emitter.emit( + routes.events.EventType.singleAttestation, + toElectraSingleAttestation( + attestation as SingleAttestation, + indexedAttestation.attestingIndices[0] + ) + ); + } const sentPeers = await network.publishBeaconAttestation(attestation, subnet); metrics?.onPoolSubmitUnaggregatedAttestation(seenTimestampSec, indexedAttestation, subnet, sentPeers); diff --git a/packages/beacon-node/src/chain/errors/attestationError.ts b/packages/beacon-node/src/chain/errors/attestationError.ts index 618a334928a..1f907be96e7 100644 --- a/packages/beacon-node/src/chain/errors/attestationError.ts +++ b/packages/beacon-node/src/chain/errors/attestationError.ts @@ -135,6 +135,10 @@ export enum AttestationErrorCode { * Electra: Invalid attestationData index: is non-zero */ NON_ZERO_ATTESTATION_DATA_INDEX = "ATTESTATION_ERROR_NON_ZERO_ATTESTATION_DATA_INDEX", + /** + * Electra: Attester not in committee + */ + ATTESTER_NOT_IN_COMMITTEE = "ATTESTATION_ERROR_ATTESTER_NOT_IN_COMMITTEE", } export type AttestationErrorType = @@ -170,7 +174,8 @@ export type AttestationErrorType = | {code: AttestationErrorCode.INVALID_SERIALIZED_BYTES} | {code: AttestationErrorCode.TOO_MANY_SKIPPED_SLOTS; headBlockSlot: Slot; attestationSlot: Slot} | {code: AttestationErrorCode.NOT_EXACTLY_ONE_COMMITTEE_BIT_SET} - | {code: AttestationErrorCode.NON_ZERO_ATTESTATION_DATA_INDEX}; + | {code: AttestationErrorCode.NON_ZERO_ATTESTATION_DATA_INDEX} + | {code: AttestationErrorCode.ATTESTER_NOT_IN_COMMITTEE}; export class AttestationError extends GossipActionError { getMetadata(): Record { diff --git a/packages/beacon-node/src/chain/opPools/attestationPool.ts b/packages/beacon-node/src/chain/opPools/attestationPool.ts index 9809c9c6304..181e9c2e78b 100644 --- a/packages/beacon-node/src/chain/opPools/attestationPool.ts +++ b/packages/beacon-node/src/chain/opPools/attestationPool.ts @@ -2,7 +2,7 @@ import {Signature, aggregateSignatures} from "@chainsafe/blst"; import {BitArray} from "@chainsafe/ssz"; import {ChainForkConfig} from "@lodestar/config"; import {isForkPostElectra} from "@lodestar/params"; -import {Attestation, RootHex, Slot, isElectraAttestation} from "@lodestar/types"; +import {Attestation, RootHex, SingleAttestation, Slot, isElectraSingleAttestation} from "@lodestar/types"; import {assert, MapDef} from "@lodestar/utils"; import {IClock} from "../../util/clock.js"; import {InsertOutcome, OpPoolError, OpPoolErrorCode} from "./types.js"; @@ -105,7 +105,13 @@ export class AttestationPool { * - Valid committeeIndex * - Valid data */ - add(committeeIndex: CommitteeIndex, attestation: Attestation, attDataRootHex: RootHex): InsertOutcome { + add( + committeeIndex: CommitteeIndex, + attestation: SingleAttestation, + attDataRootHex: RootHex, + aggregationBits: BitArray | null, + committeeBits: BitArray | null + ): InsertOutcome { const slot = attestation.data.slot; const fork = this.config.getForkName(slot); const lowestPermissibleSlot = this.lowestPermissibleSlot; @@ -129,9 +135,9 @@ export class AttestationPool { if (isForkPostElectra(fork)) { // Electra only: this should not happen because attestation should be validated before reaching this assert.notNull(committeeIndex, "Committee index should not be null in attestation pool post-electra"); - assert.true(isElectraAttestation(attestation), "Attestation should be type electra.Attestation"); + assert.true(isElectraSingleAttestation(attestation), "Attestation should be type electra.SingleAttestation"); } else { - assert.true(!isElectraAttestation(attestation), "Attestation should be type phase0.Attestation"); + assert.true(!isElectraSingleAttestation(attestation), "Attestation should be type phase0.Attestation"); committeeIndex = null; // For pre-electra, committee index info is encoded in attDataRootIndex } @@ -144,10 +150,10 @@ export class AttestationPool { const aggregate = aggregateByIndex.get(committeeIndex); if (aggregate) { // Aggregate mutating - return aggregateAttestationInto(aggregate, attestation); + return aggregateAttestationInto(aggregate, attestation, aggregationBits); } // Create new aggregate - aggregateByIndex.set(committeeIndex, attestationToAggregate(attestation)); + aggregateByIndex.set(committeeIndex, attestationToAggregate(attestation, aggregationBits, committeeBits)); return InsertOutcome.NewData; } @@ -216,8 +222,19 @@ export class AttestationPool { /** * Aggregate a new attestation into `aggregate` mutating it */ -function aggregateAttestationInto(aggregate: AggregateFast, attestation: Attestation): InsertOutcome { - const bitIndex = attestation.aggregationBits.getSingleTrueBit(); +function aggregateAttestationInto( + aggregate: AggregateFast, + attestation: SingleAttestation, + aggregationBits: BitArray | null +): InsertOutcome { + let bitIndex: number | null; + + if (isElectraSingleAttestation(attestation)) { + assert.notNull(aggregationBits, "aggregationBits missing post-electra"); + bitIndex = aggregationBits.getSingleTrueBit(); + } else { + bitIndex = attestation.aggregationBits.getSingleTrueBit(); + } // Should never happen, attestations are verified against this exact condition before assert.notNull(bitIndex, "Invalid attestation in pool, not exactly one bit set"); @@ -234,13 +251,18 @@ function aggregateAttestationInto(aggregate: AggregateFast, attestation: Attesta /** * Format `contribution` into an efficient `aggregate` to add more contributions in with aggregateContributionInto() */ -function attestationToAggregate(attestation: Attestation): AggregateFast { - if (isElectraAttestation(attestation)) { +function attestationToAggregate( + attestation: SingleAttestation, + aggregationBits: BitArray | null, + committeeBits: BitArray | null +): AggregateFast { + if (isElectraSingleAttestation(attestation)) { + assert.notNull(aggregationBits, "aggregationBits missing post-electra to generate aggregate"); + assert.notNull(committeeBits, "committeeBits missing post-electra to generate aggregate"); return { data: attestation.data, - // clone because it will be mutated - aggregationBits: attestation.aggregationBits.clone(), - committeeBits: attestation.committeeBits, + aggregationBits, + committeeBits, signature: signatureFromBytesNoCheck(attestation.signature), }; } diff --git a/packages/beacon-node/src/chain/seenCache/seenAttestationData.ts b/packages/beacon-node/src/chain/seenCache/seenAttestationData.ts index 8e0dfcb3bd9..eb95440393b 100644 --- a/packages/beacon-node/src/chain/seenCache/seenAttestationData.ts +++ b/packages/beacon-node/src/chain/seenCache/seenAttestationData.ts @@ -4,17 +4,17 @@ import {MapDef} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; import {InsertOutcome} from "../opPools/types.js"; -export type SeenAttDataKey = AttDataBase64 | AttDataCommitteeBitsBase64; -// pre-electra, AttestationData is used to cache attestations +export type SeenAttDataKey = AttDataBase64; +// AttestationData is used to cache attestations type AttDataBase64 = string; -// electra, AttestationData + CommitteeBits are used to cache attestations -type AttDataCommitteeBitsBase64 = string; export type AttestationDataCacheEntry = { // part of shuffling data, so this does not take memory committeeValidatorIndices: Uint32Array; // undefined for phase0 Attestation - committeeBits?: BitArray; + // TODO: remove this as it's not in SingleAttestation + // committeeBits?: BitArray; + // TODO: remove this? this is available in SingleAttestation committeeIndex: CommitteeIndex; // IndexedAttestationData signing root, 32 bytes signingRoot: Uint8Array; @@ -24,6 +24,10 @@ export type AttestationDataCacheEntry = { // for example in a mainnet node subscribing to all subnets, attestations are processed up to 20k per slot attestationData: phase0.AttestationData; subnet: number; + // aggregationBits only populates post-electra. Pre-electra can use get it directly from attestationOrBytes + aggregationBits: BitArray | null; + // committeeBits only populates post-electra. Pre-electra does not require it + committeeBits: BitArray | null; }; export enum RejectReason { @@ -35,6 +39,10 @@ export enum RejectReason { already_known = "already_known", } +// For pre-electra, there is no committeeIndex in SingleAttestation, so we hard code it to 0 +// AttDataBase64 has committeeIndex instead +export const PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX = 0; + /** * There are maximum 64 committees per slot, assuming 1 committee may have up to 3 different data due to some nodes * are not up to date, we can have up to 192 different attestation data per slot. @@ -53,8 +61,14 @@ const DEFAULT_CACHE_SLOT_DISTANCE = 2; * Having this cache help saves a lot of cpu time since most of the gossip attestations are on the same slot. */ export class SeenAttestationDatas { - private cacheEntryByAttDataBase64BySlot = new MapDef>( - () => new Map() + private cacheEntryByAttDataByIndexBySlot = new MapDef< + Slot, + MapDef> + >( + () => + new MapDef>( + () => new Map() + ) ); private lowestPermissibleSlot = 0; @@ -67,31 +81,47 @@ export class SeenAttestationDatas { metrics?.seenCache.attestationData.totalSlot.addCollect(() => this.onScrapeLodestarMetrics(metrics)); } - // TODO: Move InsertOutcome type definition to a common place - add(slot: Slot, attDataKey: SeenAttDataKey, cacheEntry: AttestationDataCacheEntry): InsertOutcome { + /** + * Add an AttestationDataCacheEntry to the cache. + * - preElectra: add(slot, PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX, attDataBase64, cacheEntry) + * - electra: add(slot, committeeIndex, attDataBase64, cacheEntry) + */ + add( + slot: Slot, + committeeIndex: CommitteeIndex, + attDataBase64: AttDataBase64, + cacheEntry: AttestationDataCacheEntry + ): InsertOutcome { if (slot < this.lowestPermissibleSlot) { this.metrics?.seenCache.attestationData.reject.inc({reason: RejectReason.too_old}); return InsertOutcome.Old; } - const cacheEntryByAttDataBase64 = this.cacheEntryByAttDataBase64BySlot.getOrDefault(slot); - if (cacheEntryByAttDataBase64.has(attDataKey)) { + const cacheEntryByAttDataByIndex = this.cacheEntryByAttDataByIndexBySlot.getOrDefault(slot); + const cacheEntryByAttData = cacheEntryByAttDataByIndex.getOrDefault(committeeIndex); + if (cacheEntryByAttData.has(attDataBase64)) { this.metrics?.seenCache.attestationData.reject.inc({reason: RejectReason.already_known}); return InsertOutcome.AlreadyKnown; } - if (cacheEntryByAttDataBase64.size >= this.maxCacheSizePerSlot) { + if (cacheEntryByAttData.size >= this.maxCacheSizePerSlot) { this.metrics?.seenCache.attestationData.reject.inc({reason: RejectReason.reached_limit}); return InsertOutcome.ReachLimit; } - cacheEntryByAttDataBase64.set(attDataKey, cacheEntry); + cacheEntryByAttData.set(attDataBase64, cacheEntry); return InsertOutcome.NewData; } - get(slot: Slot, attDataBase64: SeenAttDataKey): AttestationDataCacheEntry | null { - const cacheEntryByAttDataBase64 = this.cacheEntryByAttDataBase64BySlot.get(slot); - const cacheEntry = cacheEntryByAttDataBase64?.get(attDataBase64); + /** + * Get an AttestationDataCacheEntry from the cache. + * - preElectra: get(slot, PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX, attDataBase64) + * - electra: get(slot, committeeIndex, attDataBase64) + */ + get(slot: Slot, committeeIndex: CommitteeIndex, attDataBase64: SeenAttDataKey): AttestationDataCacheEntry | null { + const cacheEntryByAttDataByIndex = this.cacheEntryByAttDataByIndexBySlot.get(slot); + const cacheEntryByAttData = cacheEntryByAttDataByIndex?.get(committeeIndex); + const cacheEntry = cacheEntryByAttData?.get(attDataBase64); if (cacheEntry) { this.metrics?.seenCache.attestationData.hit.inc(); } else { @@ -102,20 +132,23 @@ export class SeenAttestationDatas { onSlot(clockSlot: Slot): void { this.lowestPermissibleSlot = Math.max(clockSlot - this.cacheSlotDistance, 0); - for (const slot of this.cacheEntryByAttDataBase64BySlot.keys()) { + for (const slot of this.cacheEntryByAttDataByIndexBySlot.keys()) { if (slot < this.lowestPermissibleSlot) { - this.cacheEntryByAttDataBase64BySlot.delete(slot); + this.cacheEntryByAttDataByIndexBySlot.delete(slot); } } } private onScrapeLodestarMetrics(metrics: Metrics): void { - metrics?.seenCache.attestationData.totalSlot.set(this.cacheEntryByAttDataBase64BySlot.size); + metrics?.seenCache.attestationData.totalSlot.set(this.cacheEntryByAttDataByIndexBySlot.size); // tracking number of attestation data at current slot may not be correct if scrape time is not at the end of slot // so we track it at the previous slot const previousSlot = this.lowestPermissibleSlot + this.cacheSlotDistance - 1; - metrics?.seenCache.attestationData.countPerSlot.set( - this.cacheEntryByAttDataBase64BySlot.get(previousSlot)?.size ?? 0 - ); + const cacheEntryByAttDataByIndex = this.cacheEntryByAttDataByIndexBySlot.get(previousSlot); + let count = 0; + for (const cacheEntryByAttDataBase64 of cacheEntryByAttDataByIndex?.values() ?? []) { + count += cacheEntryByAttDataBase64.size; + } + metrics?.seenCache.attestationData.countPerSlot.set(count); } } diff --git a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts index 781e63cf623..8f4a3dd5399 100644 --- a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts +++ b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts @@ -71,9 +71,6 @@ async function validateAggregateAndProof( const attData = aggregate.data; const attSlot = attData.slot; - const seenAttDataKey = serializedData ? getSeenAttDataKeyFromSignedAggregateAndProof(fork, serializedData) : null; - const cachedAttData = seenAttDataKey ? chain.seenAttestationDatas.get(attSlot, seenAttDataKey) : null; - let attIndex: number | null; if (ForkSeq[fork] >= ForkSeq.electra) { attIndex = (aggregate as electra.Attestation).committeeBits.getSingleTrueBit(); @@ -89,6 +86,9 @@ async function validateAggregateAndProof( attIndex = attData.index; } + const seenAttDataKey = serializedData ? getSeenAttDataKeyFromSignedAggregateAndProof(fork, serializedData) : null; + const cachedAttData = seenAttDataKey ? chain.seenAttestationDatas.get(attSlot, attIndex, seenAttDataKey) : null; + const attEpoch = computeEpochAtSlot(attSlot); const attTarget = attData.target; const targetEpoch = attTarget.epoch; diff --git a/packages/beacon-node/src/chain/validation/attestation.ts b/packages/beacon-node/src/chain/validation/attestation.ts index e49a3f79450..c6a60fb16b1 100644 --- a/packages/beacon-node/src/chain/validation/attestation.ts +++ b/packages/beacon-node/src/chain/validation/attestation.ts @@ -5,6 +5,8 @@ import { ATTESTATION_SUBNET_COUNT, DOMAIN_BEACON_ATTESTER, ForkName, + ForkPostElectra, + ForkPreElectra, ForkSeq, SLOTS_PER_EPOCH, isForkPostElectra, @@ -20,35 +22,42 @@ import { createSingleSignatureSetFromComponents, } from "@lodestar/state-transition"; import { - Attestation, CommitteeIndex, Epoch, IndexedAttestation, Root, RootHex, + SingleAttestation, Slot, + ValidatorIndex, electra, - isElectraAttestation, + isElectraSingleAttestation, phase0, ssz, } from "@lodestar/types"; -import {toRootHex} from "@lodestar/utils"; +import {assert, toRootHex} from "@lodestar/utils"; import {MAXIMUM_GOSSIP_CLOCK_DISPARITY_SEC} from "../../constants/index.js"; import {sszDeserializeAttestation} from "../../network/gossip/topic.js"; +import {sszDeserializeSingleAttestation} from "../../network/gossip/topic.js"; import {getShufflingDependentRoot} from "../../util/dependentRoot.js"; import { getAggregationBitsFromAttestationSerialized, getAttDataFromSignedAggregateAndProofElectra, getAttDataFromSignedAggregateAndProofPhase0, - getCommitteeBitsFromAttestationSerialized, + getBeaconAttestationGossipIndex, getCommitteeBitsFromSignedAggregateAndProofElectra, + getCommitteeIndexFromSingleAttestationSerialized, getSignatureFromAttestationSerialized, } from "../../util/sszBytes.js"; import {Result, wrapError} from "../../util/wrapError.js"; import {AttestationError, AttestationErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../interface.js"; import {RegenCaller} from "../regen/index.js"; -import {AttestationDataCacheEntry, SeenAttDataKey} from "../seenCache/seenAttestationData.js"; +import { + AttestationDataCacheEntry, + PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX, + SeenAttDataKey, +} from "../seenCache/seenAttestationData.js"; export type BatchResult = { results: Result[]; @@ -56,17 +65,19 @@ export type BatchResult = { }; export type AttestationValidationResult = { - attestation: Attestation; + attestation: SingleAttestation; indexedAttestation: IndexedAttestation; subnet: number; attDataRootHex: RootHex; committeeIndex: CommitteeIndex; + aggregationBits: BitArray | null; // Field populated post-electra only + committeeBits: BitArray | null; // Field populated post-electra only }; export type AttestationOrBytes = ApiAttestation | GossipAttestation; /** attestation from api */ -export type ApiAttestation = {attestation: Attestation; serializedData: null}; +export type ApiAttestation = {attestation: SingleAttestation; serializedData: null}; /** attestation from gossip */ export type GossipAttestation = { @@ -224,7 +235,7 @@ export async function validateApiAttestation( } /** - * Only deserialize the attestation if needed, use the cached AttestationData instead + * Only deserialize the single attestation if needed, use the cached AttestationData instead * This is to avoid deserializing similar attestation multiple times which could help the gc */ async function validateAttestationNoSignatureCheck( @@ -245,16 +256,20 @@ async function validateAttestationNoSignatureCheck( // Run the checks that happen before an indexed attestation is constructed. let attestationOrCache: - | {attestation: Attestation; cache: null} + | {attestation: SingleAttestation; cache: null} | {attestation: null; cache: AttestationDataCacheEntry; serializedData: Uint8Array}; let attDataKey: SeenAttDataKey | null = null; if (attestationOrBytes.serializedData) { // gossip const attSlot = attestationOrBytes.attSlot; attDataKey = getSeenAttDataKeyFromGossipAttestation(fork, attestationOrBytes); - const cachedAttData = attDataKey !== null ? chain.seenAttestationDatas.get(attSlot, attDataKey) : null; + const committeeIndexForLookup = isForkPostElectra(fork) + ? (getCommitteeIndexFromAttestationOrBytes(fork, attestationOrBytes) ?? 0) + : PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX; + const cachedAttData = + attDataKey !== null ? chain.seenAttestationDatas.get(attSlot, committeeIndexForLookup, attDataKey) : null; if (cachedAttData === null) { - const attestation = sszDeserializeAttestation(fork, attestationOrBytes.serializedData); + const attestation = sszDeserializeSingleAttestation(fork, attestationOrBytes.serializedData); // only deserialize on the first AttestationData that's not cached attestationOrCache = {attestation, cache: null}; } else { @@ -275,19 +290,9 @@ async function validateAttestationNoSignatureCheck( const targetEpoch = attTarget.epoch; let committeeIndex: number | null; if (attestationOrCache.attestation) { - if (isElectraAttestation(attestationOrCache.attestation)) { + if (isElectraSingleAttestation(attestationOrCache.attestation)) { // api or first time validation of a gossip attestation - const {committeeBits} = attestationOrCache.attestation; - // throw in both in case of undefined and null - if (committeeBits == null) { - throw new AttestationError(GossipAction.REJECT, {code: AttestationErrorCode.INVALID_SERIALIZED_BYTES}); - } - - committeeIndex = committeeBits.getSingleTrueBit(); - // [REJECT] len(committee_indices) == 1, where committee_indices = get_committee_indices(aggregate) - if (committeeIndex === null) { - throw new AttestationError(GossipAction.REJECT, {code: AttestationErrorCode.NOT_EXACTLY_ONE_COMMITTEE_BIT_SET}); - } + committeeIndex = attestationOrCache.attestation.committeeIndex; // [REJECT] aggregate.data.index == 0 if (attData.index !== 0) { @@ -321,26 +326,40 @@ async function validateAttestationNoSignatureCheck( verifyPropagationSlotRange(fork, chain, attestationOrCache.attestation.data.slot); } - // [REJECT] The attestation is unaggregated -- that is, it has exactly one participating validator - // (len([bit for bit in attestation.aggregation_bits if bit]) == 1, i.e. exactly 1 bit is set). - // > TODO: Do this check **before** getting the target state but don't recompute zipIndexes - const aggregationBits = attestationOrCache.attestation - ? attestationOrCache.attestation.aggregationBits - : getAggregationBitsFromAttestationSerialized(fork, attestationOrCache.serializedData); - if (aggregationBits === null) { - throw new AttestationError(GossipAction.REJECT, { - code: AttestationErrorCode.INVALID_SERIALIZED_BYTES, - }); - } + let aggregationBits: BitArray | null = null; + let committeeBits: BitArray | null = null; + if (!isForkPostElectra(fork)) { + // [REJECT] The attestation is unaggregated -- that is, it has exactly one participating validator + // (len([bit for bit in attestation.aggregation_bits if bit]) == 1, i.e. exactly 1 bit is set). + // > TODO: Do this check **before** getting the target state but don't recompute zipIndexes + aggregationBits = attestationOrCache.attestation + ? (attestationOrCache.attestation as SingleAttestation).aggregationBits + : getAggregationBitsFromAttestationSerialized(attestationOrCache.serializedData); + if (aggregationBits === null) { + throw new AttestationError(GossipAction.REJECT, { + code: AttestationErrorCode.INVALID_SERIALIZED_BYTES, + }); + } - const bitIndex = aggregationBits.getSingleTrueBit(); - if (bitIndex === null) { - throw new AttestationError(GossipAction.REJECT, { - code: AttestationErrorCode.NOT_EXACTLY_ONE_AGGREGATION_BIT_SET, - }); + const bitIndex = aggregationBits.getSingleTrueBit(); + if (bitIndex === null) { + throw new AttestationError(GossipAction.REJECT, { + code: AttestationErrorCode.NOT_EXACTLY_ONE_AGGREGATION_BIT_SET, + }); + } + } else { + // Populate aggregationBits if cached post-electra, else we populate later + if (attestationOrCache.cache && attestationOrCache.cache.aggregationBits !== null) { + aggregationBits = attestationOrCache.cache.aggregationBits; + } + // Populate aggregationBits if cached post-electra, else we populate later + if (attestationOrCache.cache && attestationOrCache.cache.committeeBits !== null) { + committeeBits = attestationOrCache.cache.committeeBits; + } } let committeeValidatorIndices: Uint32Array; + let numCommittees: number | null = null; // Only populate when we compute shuffling let getSigningRoot: () => Uint8Array; let expectedSubnet: number; if (attestationOrCache.cache) { @@ -389,17 +408,46 @@ async function validateAttestationNoSignatureCheck( committeeValidatorIndices = getCommitteeIndices(shuffling, attSlot, committeeIndex); getSigningRoot = () => getAttestationDataSigningRoot(chain.config, attData); expectedSubnet = computeSubnetForSlot(shuffling, attSlot, committeeIndex); + numCommittees = shuffling.committeesPerSlot; } - const validatorIndex = committeeValidatorIndices[bitIndex]; + let validatorIndex: number; - // [REJECT] The number of aggregation bits matches the committee size - // -- i.e. len(attestation.aggregation_bits) == len(get_beacon_committee(state, data.slot, data.index)). - // > TODO: Is this necessary? Lighthouse does not do this check. - if (aggregationBits.bitLen !== committeeValidatorIndices.length) { - throw new AttestationError(GossipAction.REJECT, { - code: AttestationErrorCode.WRONG_NUMBER_OF_AGGREGATION_BITS, - }); + if (!isForkPostElectra(fork)) { + // The validity of aggregation bits are already checked above + assert.notNull(aggregationBits); + const bitIndex = aggregationBits.getSingleTrueBit(); + assert.notNull(bitIndex); + + validatorIndex = committeeValidatorIndices[bitIndex]; + // [REJECT] The number of aggregation bits matches the committee size + // -- i.e. len(attestation.aggregation_bits) == len(get_beacon_committee(state, data.slot, data.index)). + // > TODO: Is this necessary? Lighthouse does not do this check. + if (aggregationBits.bitLen !== committeeValidatorIndices.length) { + throw new AttestationError(GossipAction.REJECT, { + code: AttestationErrorCode.WRONG_NUMBER_OF_AGGREGATION_BITS, + }); + } + } else { + validatorIndex = (attestationOrCache.attestation as SingleAttestation).attesterIndex; + // [REJECT] The attester is a member of the committee -- i.e. + // `attestation.attester_index in get_beacon_committee(state, attestation.data.slot, index)`. + // If `aggregationBitsElectra` exists, that means we have already cached it. No need to check again + if (aggregationBits === null) { + // Position of the validator in its committee + const committeeValidatorIndex = committeeValidatorIndices.indexOf(validatorIndex); + if (committeeValidatorIndex === -1) { + throw new AttestationError(GossipAction.REJECT, { + code: AttestationErrorCode.ATTESTER_NOT_IN_COMMITTEE, + }); + } + + aggregationBits = BitArray.fromSingleBit(committeeValidatorIndices.length, committeeValidatorIndex); + } + // If committeeBits is null, it means it is not cached and thus numCommittees must be computed + if (committeeBits === null && numCommittees !== null) { + committeeBits = BitArray.fromSingleBit(numCommittees, committeeIndex); + } } // LH > verify_middle_checks @@ -442,7 +490,6 @@ async function validateAttestationNoSignatureCheck( }); } - let committeeBits: BitArray | undefined = undefined; if (attestationOrCache.cache) { // there could be up to 6% of cpu time to compute signing root if we don't clone the signature set signatureSet = createSingleSignatureSetFromComponents( @@ -451,7 +498,6 @@ async function validateAttestationNoSignatureCheck( signature ); attDataRootHex = attestationOrCache.cache.attDataRootHex; - committeeBits = attestationOrCache.cache.committeeBits; } else { signatureSet = createSingleSignatureSetFromComponents( chain.index2pubkey[validatorIndex], @@ -461,14 +507,9 @@ async function validateAttestationNoSignatureCheck( // add cached attestation data before verifying signature attDataRootHex = toRootHex(ssz.phase0.AttestationData.hashTreeRoot(attData)); - // if attestation is phase0 the committeeBits is undefined anyway - committeeBits = isElectraAttestation(attestationOrCache.attestation) - ? attestationOrCache.attestation.committeeBits.clone() - : undefined; if (attDataKey) { - chain.seenAttestationDatas.add(attSlot, attDataKey, { + chain.seenAttestationDatas.add(attSlot, committeeIndex, attDataKey, { committeeValidatorIndices, - committeeBits, committeeIndex, signingRoot: signatureSet.signingRoot, subnet: expectedSubnet, @@ -476,6 +517,8 @@ async function validateAttestationNoSignatureCheck( // root of AttestationData was already cached during getIndexedAttestationSignatureSet attDataRootHex, attestationData: attData, + aggregationBits: isForkPostElectra(fork) ? aggregationBits : null, + committeeBits: isForkPostElectra(fork) ? committeeBits : null, }); } } @@ -491,12 +534,18 @@ async function validateAttestationNoSignatureCheck( ? (indexedAttestationContent as electra.IndexedAttestation) : (indexedAttestationContent as phase0.IndexedAttestation); - const attestation: Attestation = attestationOrCache.attestation ?? { + const attestationContent = attestationOrCache.attestation ?? { aggregationBits, data: attData, - committeeBits, + committeeIndex, signature, }; + + const attestation = + ForkSeq[fork] >= ForkSeq.electra + ? (attestationContent as SingleAttestation) + : (attestationContent as SingleAttestation); + return { attestation, indexedAttestation, @@ -505,6 +554,8 @@ async function validateAttestationNoSignatureCheck( signatureSet, validatorIndex, committeeIndex, + aggregationBits: isForkPostElectra(fork) ? aggregationBits : null, + committeeBits: isForkPostElectra(fork) ? committeeBits : null, }; } @@ -770,21 +821,16 @@ export function computeSubnetForSlot(shuffling: EpochShuffling, slot: number, co /** * Return fork-dependent seen attestation key - * - for pre-electra, it's the AttestationData base64 - * - for electra and later, it's the AttestationData base64 + committeeBits base64 + * - for pre-electra, it's the AttestationData base64 from Attestation + * - for electra and later, it's the AttestationData base64 from SingleAttestation */ export function getSeenAttDataKeyFromGossipAttestation( fork: ForkName, attestation: GossipAttestation ): SeenAttDataKey | null { const {attDataBase64, serializedData} = attestation; - if (isForkPostElectra(fork)) { - const committeeBits = getCommitteeBitsFromAttestationSerialized(serializedData); - return attDataBase64 && committeeBits ? attDataBase64 + committeeBits : null; - } - - // pre-electra - return attDataBase64; + // SeenAttDataKey is the same as gossip index + return attDataBase64 ?? getBeaconAttestationGossipIndex(fork, serializedData); } /** @@ -805,3 +851,36 @@ export function getSeenAttDataKeyFromSignedAggregateAndProof( // pre-electra return getAttDataFromSignedAggregateAndProofPhase0(aggregateAndProof); } + +export function getCommitteeIndexFromAttestationOrBytes( + fork: ForkName, + attestationOrBytes: AttestationOrBytes +): CommitteeIndex | null { + const isGossipAttestation = attestationOrBytes.serializedData !== null; + + if (isForkPostElectra(fork)) { + if (isGossipAttestation) { + return getCommitteeIndexFromSingleAttestationSerialized(ForkName.electra, attestationOrBytes.serializedData); + } + return (attestationOrBytes.attestation as SingleAttestation).committeeIndex; + } + if (isGossipAttestation) { + return getCommitteeIndexFromSingleAttestationSerialized(ForkName.phase0, attestationOrBytes.serializedData); + } + return (attestationOrBytes.attestation as SingleAttestation).data.index; +} + +/** + * Convert pre-electra single attestation (`phase0.Attestation`) to post-electra `SingleAttestation` + */ +export function toElectraSingleAttestation( + attestation: SingleAttestation, + attesterIndex: ValidatorIndex +): SingleAttestation { + return { + committeeIndex: attestation.data.index, + attesterIndex, + data: attestation.data, + signature: attestation.signature, + }; +} diff --git a/packages/beacon-node/src/network/gossip/interface.ts b/packages/beacon-node/src/network/gossip/interface.ts index 9939ed5af65..d0e84e97046 100644 --- a/packages/beacon-node/src/network/gossip/interface.ts +++ b/packages/beacon-node/src/network/gossip/interface.ts @@ -3,11 +3,11 @@ import {Message, TopicValidatorResult} from "@libp2p/interface"; import {BeaconConfig} from "@lodestar/config"; import {ForkName} from "@lodestar/params"; import { - Attestation, LightClientFinalityUpdate, LightClientOptimisticUpdate, SignedAggregateAndProof, SignedBeaconBlock, + SingleAttestation, Slot, altair, capella, @@ -86,7 +86,7 @@ export type GossipTypeMap = { [GossipType.beacon_block]: SignedBeaconBlock; [GossipType.blob_sidecar]: deneb.BlobSidecar; [GossipType.beacon_aggregate_and_proof]: SignedAggregateAndProof; - [GossipType.beacon_attestation]: Attestation; + [GossipType.beacon_attestation]: SingleAttestation; [GossipType.voluntary_exit]: phase0.SignedVoluntaryExit; [GossipType.proposer_slashing]: phase0.ProposerSlashing; [GossipType.attester_slashing]: phase0.AttesterSlashing; @@ -101,7 +101,7 @@ export type GossipFnByType = { [GossipType.beacon_block]: (signedBlock: SignedBeaconBlock) => Promise | void; [GossipType.blob_sidecar]: (blobSidecar: deneb.BlobSidecar) => Promise | void; [GossipType.beacon_aggregate_and_proof]: (aggregateAndProof: SignedAggregateAndProof) => Promise | void; - [GossipType.beacon_attestation]: (attestation: Attestation) => Promise | void; + [GossipType.beacon_attestation]: (attestation: SingleAttestation) => Promise | void; [GossipType.voluntary_exit]: (voluntaryExit: phase0.SignedVoluntaryExit) => Promise | void; [GossipType.proposer_slashing]: (proposerSlashing: phase0.ProposerSlashing) => Promise | void; [GossipType.attester_slashing]: (attesterSlashing: phase0.AttesterSlashing) => Promise | void; diff --git a/packages/beacon-node/src/network/gossip/topic.ts b/packages/beacon-node/src/network/gossip/topic.ts index de52860605a..35248576f33 100644 --- a/packages/beacon-node/src/network/gossip/topic.ts +++ b/packages/beacon-node/src/network/gossip/topic.ts @@ -6,8 +6,9 @@ import { MAX_BLOBS_PER_BLOCK, SYNC_COMMITTEE_SUBNET_COUNT, isForkLightClient, + isForkPostElectra, } from "@lodestar/params"; -import {Attestation, ssz, sszTypesFor} from "@lodestar/types"; +import {Attestation, SingleAttestation, ssz, sszTypesFor} from "@lodestar/types"; import {GossipAction, GossipActionError, GossipErrorCode} from "../../chain/errors/gossipValidation.js"; import {DEFAULT_ENCODING} from "./constants.js"; @@ -125,7 +126,9 @@ export function sszDeserialize(topic: T, serializedData: } /** + * @deprecated * Deserialize a gossip serialized data into an Attestation object. + * No longer used post-electra. Use `sszDeserializeSingleAttestation` instead */ export function sszDeserializeAttestation(fork: ForkName, serializedData: Uint8Array): Attestation { try { @@ -135,6 +138,20 @@ export function sszDeserializeAttestation(fork: ForkName, serializedData: Uint8A } } +/** + * Deserialize a gossip seralized data into an SingleAttestation object. + */ +export function sszDeserializeSingleAttestation(fork: ForkName, serializedData: Uint8Array): SingleAttestation { + try { + if (isForkPostElectra(fork)) { + return sszTypesFor(fork).SingleAttestation.deserialize(serializedData); + } + return sszTypesFor(fork).Attestation.deserialize(serializedData) as SingleAttestation; + } catch (_e) { + throw new GossipActionError(GossipAction.REJECT, {code: GossipErrorCode.INVALID_SERIALIZED_BYTES_ERROR_CODE}); + } +} + // Parsing const gossipTopicRegex = /^\/eth2\/(\w+)\/(\w+)\/(\w+)/; diff --git a/packages/beacon-node/src/network/interface.ts b/packages/beacon-node/src/network/interface.ts index bf117cc8a74..28ce77e2696 100644 --- a/packages/beacon-node/src/network/interface.ts +++ b/packages/beacon-node/src/network/interface.ts @@ -19,6 +19,7 @@ import { LightClientOptimisticUpdate, SignedAggregateAndProof, SignedBeaconBlock, + SingleAttestation, Slot, SlotRootHex, WithBytes, @@ -72,7 +73,7 @@ export interface INetwork extends INetworkCorePublic { publishBeaconBlock(signedBlock: SignedBeaconBlock): Promise; publishBlobSidecar(blobSidecar: deneb.BlobSidecar): Promise; publishBeaconAggregateAndProof(aggregateAndProof: SignedAggregateAndProof): Promise; - publishBeaconAttestation(attestation: phase0.Attestation, subnet: number): Promise; + publishBeaconAttestation(attestation: SingleAttestation, subnet: number): Promise; publishVoluntaryExit(voluntaryExit: phase0.SignedVoluntaryExit): Promise; publishBlsToExecutionChange(blsToExecutionChange: capella.SignedBLSToExecutionChange): Promise; publishProposerSlashing(proposerSlashing: phase0.ProposerSlashing): Promise; diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index 2181e21744d..ac723f61319 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -15,6 +15,7 @@ import { Root, SignedAggregateAndProof, SignedBeaconBlock, + SingleAttestation, SlotRootHex, WithBytes, altair, @@ -327,7 +328,7 @@ export class Network implements INetwork { ); } - async publishBeaconAttestation(attestation: phase0.Attestation, subnet: number): Promise { + async publishBeaconAttestation(attestation: SingleAttestation, subnet: number): Promise { const fork = this.config.getForkName(attestation.data.slot); return this.publishGossip( {type: GossipType.beacon_attestation, fork, subnet}, diff --git a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts index 57a4861b4cb..d478078d5df 100644 --- a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts +++ b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts @@ -1,8 +1,9 @@ +import {ForkName} from "@lodestar/params"; import {SlotOptionalRoot, SlotRootHex} from "@lodestar/types"; import { - getBlockRootFromAttestationSerialized, + getBlockRootFromBeaconAttestationSerialized, getBlockRootFromSignedAggregateAndProofSerialized, - getSlotFromAttestationSerialized, + getSlotFromBeaconAttestationSerialized, getSlotFromBlobSidecarSerialized, getSlotFromSignedAggregateAndProofSerialized, getSlotFromSignedBeaconBlockSerialized, @@ -16,9 +17,9 @@ import {ExtractSlotRootFns} from "./types.js"; */ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { return { - [GossipType.beacon_attestation]: (data: Uint8Array): SlotRootHex | null => { - const slot = getSlotFromAttestationSerialized(data); - const root = getBlockRootFromAttestationSerialized(data); + [GossipType.beacon_attestation]: (data: Uint8Array, fork: ForkName): SlotRootHex | null => { + const slot = getSlotFromBeaconAttestationSerialized(fork, data); + const root = getBlockRootFromBeaconAttestationSerialized(fork, data); if (slot === null || root === null) { return null; diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 5bec263a45c..b43c61d8b04 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -1,8 +1,8 @@ import {routes} from "@lodestar/api"; import {BeaconConfig, ChainForkConfig} from "@lodestar/config"; -import {ForkName, ForkSeq} from "@lodestar/params"; +import {ForkName, ForkPostElectra, ForkPreElectra, ForkSeq, isForkPostElectra} from "@lodestar/params"; import {computeTimeAtSlot} from "@lodestar/state-transition"; -import {Root, SignedBeaconBlock, Slot, UintNum64, deneb, ssz, sszTypesFor} from "@lodestar/types"; +import {Root, SignedBeaconBlock, SingleAttestation, Slot, UintNum64, deneb, ssz, sszTypesFor} from "@lodestar/types"; import {LogLevel, Logger, prettyBytes, toRootHex} from "@lodestar/utils"; import { BlobSidecarValidation, @@ -28,6 +28,7 @@ import {validateGossipBlobSidecar} from "../../chain/validation/blobSidecar.js"; import { AggregateAndProofValidationResult, GossipAttestation, + toElectraSingleAttestation, validateGossipAggregateAndProof, validateGossipAttestationsSameAttData, validateGossipAttesterSlashing, @@ -633,14 +634,22 @@ function getBatchHandlers(modules: ValidatorFnsModules, options: GossipHandlerOp results.push(null); // Handler - const {indexedAttestation, attDataRootHex, attestation, committeeIndex} = validationResult.result; + const {indexedAttestation, attDataRootHex, attestation, committeeIndex, aggregationBits, committeeBits} = + validationResult.result; metrics?.registerGossipUnaggregatedAttestation(gossipHandlerParams[i].seenTimestampSec, indexedAttestation); try { // Node may be subscribe to extra subnets (long-lived random subnets). For those, validate the messages // but don't add to attestation pool, to save CPU and RAM if (aggregatorTracker.shouldAggregate(subnet, indexedAttestation.data.slot)) { - const insertOutcome = chain.attestationPool.add(committeeIndex, attestation, attDataRootHex); + // TODO: modify after we change attestationPool due to SingleAttestation + const insertOutcome = chain.attestationPool.add( + committeeIndex, + attestation, + attDataRootHex, + aggregationBits, + committeeBits + ); metrics?.opPool.attestationPoolInsertOutcome.inc({insertOutcome}); } } catch (e) { @@ -655,7 +664,21 @@ function getBatchHandlers(modules: ValidatorFnsModules, options: GossipHandlerOp } } - chain.emitter.emit(routes.events.EventType.attestation, attestation); + if (isForkPostElectra(fork)) { + chain.emitter.emit( + routes.events.EventType.singleAttestation, + attestation as SingleAttestation + ); + } else { + chain.emitter.emit(routes.events.EventType.attestation, attestation as SingleAttestation); + chain.emitter.emit( + routes.events.EventType.singleAttestation, + toElectraSingleAttestation( + attestation as SingleAttestation, + indexedAttestation.attestingIndices[0] + ) + ); + } } if (batchableBls) { diff --git a/packages/beacon-node/src/network/processor/gossipQueues/index.ts b/packages/beacon-node/src/network/processor/gossipQueues/index.ts index b76ebe2d875..4958fd8a50e 100644 --- a/packages/beacon-node/src/network/processor/gossipQueues/index.ts +++ b/packages/beacon-node/src/network/processor/gossipQueues/index.ts @@ -1,5 +1,5 @@ import {mapValues} from "@lodestar/utils"; -import {getGossipAttestationIndex} from "../../../util/sszBytes.js"; +import {getBeaconAttestationGossipIndex} from "../../../util/sszBytes.js"; import {BatchGossipType, GossipType, SequentialGossipType} from "../../gossip/interface.js"; import {PendingGossipsubMessage} from "../types.js"; import {IndexedGossipQueueMinSize} from "./indexed.js"; @@ -72,8 +72,7 @@ const indexedGossipQueueOpts: { // this topic may cause node to be overload and drop 100% of lower priority queues maxLength: 24576, indexFn: (item: PendingGossipsubMessage) => { - // Note indexFn is fork agnostic despite changes introduced in Electra - return getGossipAttestationIndex(item.msg.data); + return getBeaconAttestationGossipIndex(item.topic.fork, item.msg.data); }, minChunkSize: MIN_SIGNATURE_SETS_TO_BATCH_VERIFY, maxChunkSize: MAX_GOSSIP_ATTESTATION_BATCH_SIZE, diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index 4bfc4263adc..2b471034aee 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -247,7 +247,7 @@ export class NetworkProcessor { const extractBlockSlotRootFn = this.extractBlockSlotRootFns[topicType]; // check block root of Attestation and SignedAggregateAndProof messages if (extractBlockSlotRootFn) { - const slotRoot = extractBlockSlotRootFn(message.msg.data); + const slotRoot = extractBlockSlotRootFn(message.msg.data, message.topic.fork); // if slotRoot is null, it means the msg.data is invalid // in that case message will be rejected when deserializing data in later phase (gossipValidatorFn) if (slotRoot) { diff --git a/packages/beacon-node/src/network/processor/types.ts b/packages/beacon-node/src/network/processor/types.ts index ec78116bc76..c059f77eb72 100644 --- a/packages/beacon-node/src/network/processor/types.ts +++ b/packages/beacon-node/src/network/processor/types.ts @@ -1,4 +1,5 @@ import {Message} from "@libp2p/interface"; +import {ForkName} from "@lodestar/params"; import {Slot, SlotOptionalRoot} from "@lodestar/types"; import {PeerIdStr} from "../../util/peerId.js"; import {GossipTopic, GossipType} from "../gossip/index.js"; @@ -22,5 +23,5 @@ export type PendingGossipsubMessage = { }; export type ExtractSlotRootFns = { - [K in GossipType]?: (data: Uint8Array) => SlotOptionalRoot | null; + [K in GossipType]?: (data: Uint8Array, forkName: ForkName) => SlotOptionalRoot | null; }; diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index cb80d5d1bb4..8dca248b583 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -5,8 +5,9 @@ import { ForkName, ForkSeq, MAX_COMMITTEES_PER_SLOT, + isForkPostElectra, } from "@lodestar/params"; -import {BLSSignature, RootHex, Slot} from "@lodestar/types"; +import {BLSSignature, CommitteeIndex, RootHex, Slot} from "@lodestar/types"; export type BlockRootHex = RootHex; // pre-electra, AttestationData is used to cache attestations @@ -26,6 +27,12 @@ export type CommitteeBitsBase64 = string; // data: AttestationData - target data - 128 // signature: BLSSignature - 96 // committee_bits: BitVector[MAX_COMMITTEES_PER_SLOT] +// electra +// class SingleAttestation(Container): +// committeeIndex: CommitteeIndex - data 8 +// attesterIndex: ValidatorIndex - data 8 +// data: AttestationData - data 128 +// signature: BLSSignature - data 96 // // for all forks // class AttestationData(Container): 128 bytes fixed size @@ -39,10 +46,17 @@ const VARIABLE_FIELD_OFFSET = 4; const ATTESTATION_BEACON_BLOCK_ROOT_OFFSET = VARIABLE_FIELD_OFFSET + 8 + 8; const ROOT_SIZE = 32; const SLOT_SIZE = 8; +const COMMITTEE_INDEX_SIZE = 8; const ATTESTATION_DATA_SIZE = 128; // MAX_COMMITTEES_PER_SLOT is in bit, need to convert to byte const COMMITTEE_BITS_SIZE = Math.max(Math.ceil(MAX_COMMITTEES_PER_SLOT / 8), 1); const SIGNATURE_SIZE = 96; +const SINGLE_ATTESTATION_ATTDATA_OFFSET = 8 + 8; +const SINGLE_ATTESTATION_SLOT_OFFSET = SINGLE_ATTESTATION_ATTDATA_OFFSET; +const SINGLE_ATTESTATION_COMMITTEE_INDEX_OFFSET = 0; +const SINGLE_ATTESTATION_BEACON_BLOCK_ROOT_OFFSET = SINGLE_ATTESTATION_ATTDATA_OFFSET + 8 + 8; +const SINGLE_ATTESTATION_SIGNATURE_OFFSET = SINGLE_ATTESTATION_ATTDATA_OFFSET + ATTESTATION_DATA_SIZE; +const SINGLE_ATTESTATION_SIZE = SINGLE_ATTESTATION_SIGNATURE_OFFSET + SIGNATURE_SIZE; // shared Buffers to convert bytes to hex/base64 const blockRootBuf = Buffer.alloc(ROOT_SIZE); @@ -91,21 +105,43 @@ export function getAttDataFromAttestationSerialized(data: Uint8Array): AttDataBa } /** - * Alias of `getAttDataFromAttestationSerialized` specifically for batch handling indexing in gossip queue + * Extract AttDataBase64 from `beacon_attestation` gossip message serialized bytes. + * This is used for GossipQueue. */ -export function getGossipAttestationIndex(data: Uint8Array): AttDataBase64 | null { - return getAttDataFromAttestationSerialized(data); +export function getBeaconAttestationGossipIndex(fork: ForkName, data: Uint8Array): AttDataBase64 | null { + const forkSeq = ForkSeq[fork]; + return forkSeq >= ForkSeq.electra + ? getAttDataFromSingleAttestationSerialized(data) + : getAttDataFromAttestationSerialized(data); +} + +/** + * Extract slot from `beacon_attestation` gossip message serialized bytes. + */ +export function getSlotFromBeaconAttestationSerialized(fork: ForkName, data: Uint8Array): Slot | null { + const forkSeq = ForkSeq[fork]; + return forkSeq >= ForkSeq.electra + ? getSlotFromSingleAttestationSerialized(data) + : getSlotFromAttestationSerialized(data); +} + +/** + * Extract block root from `beacon_attestation` gossip message serialized bytes. + */ +export function getBlockRootFromBeaconAttestationSerialized(fork: ForkName, data: Uint8Array): BlockRootHex | null { + const forkSeq = ForkSeq[fork]; + return forkSeq >= ForkSeq.electra + ? getBlockRootFromSingleAttestationSerialized(data) + : getBlockRootFromAttestationSerialized(data); } /** * Extract aggregation bits from attestation serialized bytes. * Return null if data is not long enough to extract aggregation bits. + * Pre-electra attestation only */ -export function getAggregationBitsFromAttestationSerialized(fork: ForkName, data: Uint8Array): BitArray | null { - const aggregationBitsStartIndex = - ForkSeq[fork] >= ForkSeq.electra - ? VARIABLE_FIELD_OFFSET + ATTESTATION_DATA_SIZE + SIGNATURE_SIZE + COMMITTEE_BITS_SIZE - : VARIABLE_FIELD_OFFSET + ATTESTATION_DATA_SIZE + SIGNATURE_SIZE; +export function getAggregationBitsFromAttestationSerialized(data: Uint8Array): BitArray | null { + const aggregationBitsStartIndex = VARIABLE_FIELD_OFFSET + ATTESTATION_DATA_SIZE + SIGNATURE_SIZE; if (data.length < aggregationBitsStartIndex) { return null; @@ -130,18 +166,82 @@ export function getSignatureFromAttestationSerialized(data: Uint8Array): BLSSign } /** - * Extract committee bits from Electra attestation serialized bytes. - * Return null if data is not long enough to extract committee bits. + * Extract slot from SingleAttestation serialized bytes. + * Return null if data is not long enough to extract slot. */ -export function getCommitteeBitsFromAttestationSerialized(data: Uint8Array): CommitteeBitsBase64 | null { - const committeeBitsStartIndex = VARIABLE_FIELD_OFFSET + ATTESTATION_DATA_SIZE + SIGNATURE_SIZE; +export function getSlotFromSingleAttestationSerialized(data: Uint8Array): Slot | null { + if (data.length !== SINGLE_ATTESTATION_SIZE) { + return null; + } - if (data.length < committeeBitsStartIndex + COMMITTEE_BITS_SIZE) { + return getSlotFromOffset(data, SINGLE_ATTESTATION_SLOT_OFFSET); +} + +/** + * Extract committee index from SingleAttestation serialized bytes. + * Return null if data is not long enough to extract slot. + * TODO Electra: Rename getSlotFromOffset to reflect generic usage + */ +export function getCommitteeIndexFromSingleAttestationSerialized( + fork: ForkName, + data: Uint8Array +): CommitteeIndex | null { + if (isForkPostElectra(fork)) { + if (data.length !== SINGLE_ATTESTATION_SIZE) { + return null; + } + + return getSlotFromOffset(data, SINGLE_ATTESTATION_COMMITTEE_INDEX_OFFSET); + } + + if (data.length < VARIABLE_FIELD_OFFSET + SLOT_SIZE + COMMITTEE_INDEX_SIZE) { return null; } - committeeBitsDataBuf.set(data.subarray(committeeBitsStartIndex, committeeBitsStartIndex + COMMITTEE_BITS_SIZE)); - return committeeBitsDataBuf.toString("base64"); + return getSlotFromOffset(data, VARIABLE_FIELD_OFFSET + SLOT_SIZE); +} + +/** + * Extract block root from SingleAttestation serialized bytes. + * Return null if data is not long enough to extract block root. + */ +export function getBlockRootFromSingleAttestationSerialized(data: Uint8Array): BlockRootHex | null { + if (data.length !== SINGLE_ATTESTATION_SIZE) { + return null; + } + + blockRootBuf.set( + data.subarray(SINGLE_ATTESTATION_BEACON_BLOCK_ROOT_OFFSET, SINGLE_ATTESTATION_BEACON_BLOCK_ROOT_OFFSET + ROOT_SIZE) + ); + return `0x${blockRootBuf.toString("hex")}`; +} + +/** + * Extract attestation data base64 from SingleAttestation serialized bytes. + * Return null if data is not long enough to extract attestation data. + */ +export function getAttDataFromSingleAttestationSerialized(data: Uint8Array): AttDataBase64 | null { + if (data.length !== SINGLE_ATTESTATION_SIZE) { + return null; + } + + // base64 is a bit efficient than hex + attDataBuf.set( + data.subarray(SINGLE_ATTESTATION_ATTDATA_OFFSET, SINGLE_ATTESTATION_ATTDATA_OFFSET + ATTESTATION_DATA_SIZE) + ); + return attDataBuf.toString("base64"); +} + +/** + * Extract signature from SingleAttestation serialized bytes. + * Return null if data is not long enough to extract signature. + */ +export function getSignatureFromSingleAttestationSerialized(data: Uint8Array): BLSSignature | null { + if (data.length !== SINGLE_ATTESTATION_SIZE) { + return null; + } + + return data.subarray(SINGLE_ATTESTATION_SIGNATURE_OFFSET, SINGLE_ATTESTATION_SIGNATURE_OFFSET + SIGNATURE_SIZE); } // diff --git a/packages/beacon-node/test/memory/seenAttestationData.ts b/packages/beacon-node/test/memory/seenAttestationData.ts index 44a82dcd584..53aa519f40f 100644 --- a/packages/beacon-node/test/memory/seenAttestationData.ts +++ b/packages/beacon-node/test/memory/seenAttestationData.ts @@ -35,7 +35,7 @@ function getRandomSeenAttestationDatas(n: number): SeenAttestationDatas { attDataRootHex: toHexString(crypto.randomBytes(32)), subnet: i, } as unknown as AttestationDataCacheEntry; - seenAttestationDatas.add(slot, key, attDataCacheEntry); + seenAttestationDatas.add(slot, i, key, attDataCacheEntry); } return seenAttestationDatas; } diff --git a/packages/beacon-node/test/unit/chain/opPools/attestationPool.test.ts b/packages/beacon-node/test/unit/chain/opPools/attestationPool.test.ts index fd22f9a7c6a..9eacb3226b2 100644 --- a/packages/beacon-node/test/unit/chain/opPools/attestationPool.test.ts +++ b/packages/beacon-node/test/unit/chain/opPools/attestationPool.test.ts @@ -1,6 +1,6 @@ -import {fromHexString, toHexString} from "@chainsafe/ssz"; +import {BitArray, fromHexString, toHexString} from "@chainsafe/ssz"; import {createChainForkConfig, defaultChainConfig} from "@lodestar/config"; -import {GENESIS_SLOT, SLOTS_PER_EPOCH} from "@lodestar/params"; +import {GENESIS_SLOT, MAX_COMMITTEES_PER_SLOT, MAX_VALIDATORS_PER_COMMITTEE, SLOTS_PER_EPOCH} from "@lodestar/params"; import {ssz} from "@lodestar/types"; import {beforeEach, describe, expect, it, vi} from "vitest"; import {AttestationPool} from "../../../../src/chain/opPools/attestationPool.js"; @@ -51,8 +51,10 @@ describe("AttestationPool", () => { it("add correct electra attestation", () => { const committeeIndex = 0; + const committeeBits = BitArray.fromSingleBit(MAX_COMMITTEES_PER_SLOT, committeeIndex); + const aggregationBits = ssz.electra.Attestation.fields.aggregationBits.defaultValue(); const attDataRootHex = toHexString(ssz.phase0.AttestationData.hashTreeRoot(electraAttestation.data)); - const outcome = pool.add(committeeIndex, electraAttestation, attDataRootHex); + const outcome = pool.add(committeeIndex, electraAttestation, attDataRootHex, aggregationBits, committeeBits); expect(outcome).equal(InsertOutcome.NewData); expect(pool.getAggregate(electraAttestationData.slot, committeeIndex, attDataRootHex)).toEqual(electraAttestation); @@ -61,7 +63,7 @@ describe("AttestationPool", () => { it("add correct phase0 attestation", () => { const committeeIndex = null; const attDataRootHex = toHexString(ssz.phase0.AttestationData.hashTreeRoot(phase0Attestation.data)); - const outcome = pool.add(committeeIndex, phase0Attestation, attDataRootHex); + const outcome = pool.add(committeeIndex, phase0Attestation, attDataRootHex, null, null); expect(outcome).equal(InsertOutcome.NewData); expect(pool.getAggregate(phase0AttestationData.slot, committeeIndex, attDataRootHex)).toEqual(phase0Attestation); @@ -72,16 +74,22 @@ describe("AttestationPool", () => { it("add electra attestation without committee index", () => { const committeeIndex = null; + const committeeBits = ssz.electra.Attestation.fields.committeeBits.defaultValue(); + const aggregationBits = ssz.electra.Attestation.fields.aggregationBits.defaultValue(); const attDataRootHex = toHexString(ssz.phase0.AttestationData.hashTreeRoot(electraAttestation.data)); - expect(() => pool.add(committeeIndex, electraAttestation, attDataRootHex)).toThrow(); + expect(() => + pool.add(committeeIndex, electraAttestation, attDataRootHex, aggregationBits, committeeBits) + ).toThrow(); expect(pool.getAggregate(electraAttestationData.slot, committeeIndex, attDataRootHex)).toBeNull(); }); it("add phase0 attestation with committee index", () => { const committeeIndex = 0; + const committeeBits = BitArray.fromSingleBit(MAX_COMMITTEES_PER_SLOT, committeeIndex); + const aggregationBits = ssz.electra.Attestation.fields.aggregationBits.defaultValue(); const attDataRootHex = toHexString(ssz.phase0.AttestationData.hashTreeRoot(phase0Attestation.data)); - const outcome = pool.add(committeeIndex, phase0Attestation, attDataRootHex); + const outcome = pool.add(committeeIndex, phase0Attestation, attDataRootHex, aggregationBits, committeeBits); expect(outcome).equal(InsertOutcome.NewData); expect(pool.getAggregate(phase0AttestationData.slot, committeeIndex, attDataRootHex)).toEqual(phase0Attestation); @@ -92,6 +100,8 @@ describe("AttestationPool", () => { it("add electra attestation with phase0 slot", () => { const electraAttestationDataWithPhase0Slot = {...ssz.phase0.AttestationData.defaultValue(), slot: GENESIS_SLOT}; + const committeeBits = ssz.electra.Attestation.fields.committeeBits.defaultValue(); + const aggregationBits = ssz.electra.Attestation.fields.aggregationBits.defaultValue(); const attestation = { ...ssz.electra.Attestation.defaultValue(), data: electraAttestationDataWithPhase0Slot, @@ -99,7 +109,7 @@ describe("AttestationPool", () => { }; const attDataRootHex = toHexString(ssz.phase0.AttestationData.hashTreeRoot(electraAttestationDataWithPhase0Slot)); - expect(() => pool.add(0, attestation, attDataRootHex)).toThrow(); + expect(() => pool.add(0, attestation, attDataRootHex, aggregationBits, committeeBits)).toThrow(); }); it("add phase0 attestation with electra slot", () => { @@ -114,6 +124,6 @@ describe("AttestationPool", () => { }; const attDataRootHex = toHexString(ssz.phase0.AttestationData.hashTreeRoot(phase0AttestationDataWithElectraSlot)); - expect(() => pool.add(0, attestation, attDataRootHex)).toThrow(); + expect(() => pool.add(0, attestation, attDataRootHex, null, null)).toThrow(); }); }); diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenAttestationData.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenAttestationData.test.ts index ee5cb94ae95..aaac18b6f33 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenAttestationData.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenAttestationData.test.ts @@ -1,6 +1,10 @@ import {beforeEach, describe, expect, it} from "vitest"; import {InsertOutcome} from "../../../../src/chain/opPools/types.js"; -import {AttestationDataCacheEntry, SeenAttestationDatas} from "../../../../src/chain/seenCache/seenAttestationData.js"; +import { + AttestationDataCacheEntry, + PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX, + SeenAttestationDatas, +} from "../../../../src/chain/seenCache/seenAttestationData.js"; // Compare this snippet from packages/beacon-node/src/chain/seenCache/seenAttestationData.ts: describe("SeenAttestationDatas", () => { @@ -11,9 +15,15 @@ describe("SeenAttestationDatas", () => { beforeEach(() => { cache = new SeenAttestationDatas(null, 1, 2); cache.onSlot(100); - cache.add(99, "99a", {attDataRootHex: "99a"} as AttestationDataCacheEntry); - cache.add(99, "99b", {attDataRootHex: "99b"} as AttestationDataCacheEntry); - cache.add(100, "100a", {attDataRootHex: "100a"} as AttestationDataCacheEntry); + cache.add(99, PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX, "99a", { + attDataRootHex: "99a", + } as AttestationDataCacheEntry); + cache.add(99, PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX, "99b", { + attDataRootHex: "99b", + } as AttestationDataCacheEntry); + cache.add(100, PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX, "100a", { + attDataRootHex: "100a", + } as AttestationDataCacheEntry); }); const addTestCases: {slot: number; attDataBase64: string; expected: InsertOutcome}[] = [ @@ -26,7 +36,7 @@ describe("SeenAttestationDatas", () => { for (const testCase of addTestCases) { it(`add slot ${testCase.slot} data ${testCase.attDataBase64} should return ${testCase.expected}`, () => { expect( - cache.add(testCase.slot, testCase.attDataBase64, { + cache.add(testCase.slot, PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX, testCase.attDataBase64, { attDataRootHex: testCase.attDataBase64, } as AttestationDataCacheEntry) ).toBe(testCase.expected); @@ -44,9 +54,13 @@ describe("SeenAttestationDatas", () => { testCase.expectedNull ? "null" : "not null" }`, () => { if (testCase.expectedNull) { - expect(cache.get(testCase.slot, testCase.attDataBase64)).toBeNull(); + expect( + cache.get(testCase.slot, PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX, testCase.attDataBase64) + ).toBeNull(); } else { - expect(cache.get(testCase.slot, testCase.attDataBase64)).not.toBeNull(); + expect( + cache.get(testCase.slot, PRE_ELECTRA_SINGLE_ATTESTATION_COMMITTEE_INDEX, testCase.attDataBase64) + ).not.toBeNull(); } }); } diff --git a/packages/beacon-node/test/unit/util/sszBytes.test.ts b/packages/beacon-node/test/unit/util/sszBytes.test.ts index 8fd6011e625..ef6b713dccd 100644 --- a/packages/beacon-node/test/unit/util/sszBytes.test.ts +++ b/packages/beacon-node/test/unit/util/sszBytes.test.ts @@ -1,28 +1,44 @@ import {BitArray} from "@chainsafe/ssz"; import {ForkName, MAX_COMMITTEES_PER_SLOT} from "@lodestar/params"; -import {Epoch, RootHex, Slot, deneb, electra, isElectraAttestation, phase0, ssz} from "@lodestar/types"; -import {fromHex, toHex} from "@lodestar/utils"; +import { + CommitteeIndex, + Epoch, + RootHex, + SingleAttestation, + Slot, + deneb, + electra, + isElectraSingleAttestation, + phase0, + ssz, + sszTypesFor, +} from "@lodestar/types"; +import {fromHex, toHex, toRootHex} from "@lodestar/utils"; import {describe, expect, it} from "vitest"; import { getAggregationBitsFromAttestationSerialized, getAttDataFromAttestationSerialized, getAttDataFromSignedAggregateAndProofElectra, getAttDataFromSignedAggregateAndProofPhase0, + getAttDataFromSingleAttestationSerialized, getBlockRootFromAttestationSerialized, getBlockRootFromSignedAggregateAndProofSerialized, - getCommitteeBitsFromAttestationSerialized, + getBlockRootFromSingleAttestationSerialized, getCommitteeBitsFromSignedAggregateAndProofElectra, + getCommitteeIndexFromSingleAttestationSerialized, getSignatureFromAttestationSerialized, + getSignatureFromSingleAttestationSerialized, getSlotFromAttestationSerialized, getSlotFromBlobSidecarSerialized, getSlotFromSignedAggregateAndProofSerialized, getSlotFromSignedBeaconBlockSerialized, + getSlotFromSingleAttestationSerialized, } from "../../../src/util/sszBytes.js"; -describe("attestation SSZ serialized picking", () => { - const testCases: (phase0.Attestation | electra.Attestation)[] = [ +describe("SinlgeAttestation SSZ serialized picking", () => { + const testCases: SingleAttestation[] = [ ssz.phase0.Attestation.defaultValue(), - attestationFromValues( + phase0SingleAttestationFromValues( 4_000_000, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 200_00, @@ -30,46 +46,51 @@ describe("attestation SSZ serialized picking", () => { ), ssz.electra.Attestation.defaultValue(), { - ...attestationFromValues( + ...electraSingleAttestationFromValues( 4_000_000, + 127, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 200_00, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeffffffffffffffffffffffffffffffff" ), - committeeBits: BitArray.fromSingleBit(MAX_COMMITTEES_PER_SLOT, 3), }, ]; for (const [i, attestation] of testCases.entries()) { it(`attestation ${i}`, () => { - const isElectra = isElectraAttestation(attestation); + const isElectra = isElectraSingleAttestation(attestation); const bytes = isElectra - ? ssz.electra.Attestation.serialize(attestation) + ? sszTypesFor(ForkName.electra, "SingleAttestation").serialize(attestation) : ssz.phase0.Attestation.serialize(attestation); - expect(getSlotFromAttestationSerialized(bytes)).toBe(attestation.data.slot); - expect(getBlockRootFromAttestationSerialized(bytes)).toBe(toHex(attestation.data.beaconBlockRoot)); - if (isElectra) { - expect(getAggregationBitsFromAttestationSerialized(ForkName.electra, bytes)?.toBoolArray()).toEqual( - attestation.aggregationBits.toBoolArray() + expect(getSlotFromSingleAttestationSerialized(bytes)).toEqual(attestation.data.slot); + expect(getCommitteeIndexFromSingleAttestationSerialized(ForkName.electra, bytes)).toEqual( + attestation.committeeIndex ); - expect(getCommitteeBitsFromAttestationSerialized(bytes)).toEqual( - Buffer.from(attestation.committeeBits.uint8Array).toString("base64") + expect(getBlockRootFromSingleAttestationSerialized(bytes)).toEqual(toRootHex(attestation.data.beaconBlockRoot)); + // base64, not hex + expect(getAttDataFromSingleAttestationSerialized(bytes)).toEqual( + Buffer.from(ssz.phase0.AttestationData.serialize(attestation.data)).toString("base64") ); - expect(getSignatureFromAttestationSerialized(bytes)).toEqual(attestation.signature); + expect(getSignatureFromSingleAttestationSerialized(bytes)).toEqual(attestation.signature); } else { - expect(getAggregationBitsFromAttestationSerialized(ForkName.phase0, bytes)?.toBoolArray()).toEqual( + expect(getSlotFromAttestationSerialized(bytes)).toBe(attestation.data.slot); + expect(getCommitteeIndexFromSingleAttestationSerialized(ForkName.phase0, bytes)).toEqual( + attestation.data.index + ); + expect(getBlockRootFromAttestationSerialized(bytes)).toBe(toRootHex(attestation.data.beaconBlockRoot)); + expect(getAggregationBitsFromAttestationSerialized(bytes)?.toBoolArray()).toEqual( attestation.aggregationBits.toBoolArray() ); + const attDataBase64 = ssz.phase0.AttestationData.serialize(attestation.data); + expect(getAttDataFromAttestationSerialized(bytes)).toBe(Buffer.from(attDataBase64).toString("base64")); expect(getSignatureFromAttestationSerialized(bytes)).toEqual(attestation.signature); } - - const attDataBase64 = ssz.phase0.AttestationData.serialize(attestation.data); - expect(getAttDataFromAttestationSerialized(bytes)).toBe(Buffer.from(attDataBase64).toString("base64")); }); } + // negative tests for phase0 it("getSlotFromAttestationSerialized - invalid data", () => { const invalidSlotDataSizes = [0, 4, 11]; for (const size of invalidSlotDataSizes) { @@ -94,8 +115,8 @@ describe("attestation SSZ serialized picking", () => { it("getAggregationBitsFromAttestationSerialized - invalid data", () => { const invalidAggregationBitsDataSizes = [0, 4, 100, 128, 227]; for (const size of invalidAggregationBitsDataSizes) { - expect(getAggregationBitsFromAttestationSerialized(ForkName.phase0, Buffer.alloc(size))).toBeNull(); - expect(getAggregationBitsFromAttestationSerialized(ForkName.electra, Buffer.alloc(size))).toBeNull(); + expect(getAggregationBitsFromAttestationSerialized(Buffer.alloc(size))).toBeNull(); + expect(getAggregationBitsFromAttestationSerialized(Buffer.alloc(size))).toBeNull(); } }); @@ -106,6 +127,42 @@ describe("attestation SSZ serialized picking", () => { expect(getSignatureFromAttestationSerialized(Buffer.alloc(size))).toBeNull(); } }); + + // negative tests for electra + it("getSlotFromSingleAttestationSerialized - invalid data", () => { + const invalidSlotDataSizes = [0, 4, 11]; + for (const size of invalidSlotDataSizes) { + expect(getSlotFromSingleAttestationSerialized(Buffer.alloc(size))).toBeNull(); + } + }); + + it("getCommitteeIndexFromSingleAttestationSerialized - invalid data", () => { + const invalidCommitteeIndexDataSizes = [0, 4, 11]; + for (const size of invalidCommitteeIndexDataSizes) { + expect(getCommitteeIndexFromSingleAttestationSerialized(ForkName.electra, Buffer.alloc(size))).toBeNull(); + } + }); + + it("getBlockRootFromSingleAttestationSerialized - invalid data", () => { + const invalidBlockRootDataSizes = [0, 4, 20, 49]; + for (const size of invalidBlockRootDataSizes) { + expect(getBlockRootFromSingleAttestationSerialized(Buffer.alloc(size))).toBeNull(); + } + }); + + it("getAttDataFromSingleAttestationSerialized - invalid data", () => { + const invalidAttDataBase64DataSizes = [0, 4, 100, 128, 131]; + for (const size of invalidAttDataBase64DataSizes) { + expect(getAttDataFromSingleAttestationSerialized(Buffer.alloc(size))).toBeNull(); + } + }); + + it("getSignatureFromSingleAttestationSerialized - invalid data", () => { + const invalidSignatureDataSizes = [0, 4, 100, 128, 227]; + for (const size of invalidSignatureDataSizes) { + expect(getSignatureFromSingleAttestationSerialized(Buffer.alloc(size))).toBeNull(); + } + }); }); describe("phase0 SignedAggregateAndProof SSZ serialized picking", () => { @@ -258,7 +315,7 @@ describe("BlobSidecar SSZ serialized picking", () => { }); }); -function attestationFromValues( +function phase0SingleAttestationFromValues( slot: Slot, blockRoot: RootHex, targetEpoch: Epoch, @@ -272,6 +329,22 @@ function attestationFromValues( return attestation; } +function electraSingleAttestationFromValues( + slot: Slot, + committeeIndex: CommitteeIndex, + blockRoot: RootHex, + targetEpoch: Epoch, + targetRoot: RootHex +): electra.SingleAttestation { + const attestation = ssz.electra.SingleAttestation.defaultValue(); + attestation.data.slot = slot; + attestation.data.beaconBlockRoot = fromHex(blockRoot); + attestation.data.target.epoch = targetEpoch; + attestation.data.target.root = fromHex(targetRoot); + attestation.committeeIndex = committeeIndex; + return attestation; +} + function phase0SignedAggregateAndProofFromValues( slot: Slot, blockRoot: RootHex, diff --git a/packages/types/src/electra/sszTypes.ts b/packages/types/src/electra/sszTypes.ts index f6b6c745803..238e6cc29c7 100644 --- a/packages/types/src/electra/sszTypes.ts +++ b/packages/types/src/electra/sszTypes.ts @@ -44,6 +44,7 @@ const { UintBn64, ExecutionAddress, ValidatorIndex, + CommitteeIndex, } = primitiveSsz; export const AggregationBits = new BitListType(MAX_VALIDATORS_PER_COMMITTEE * MAX_COMMITTEES_PER_SLOT); @@ -67,6 +68,17 @@ export const Attestation = new ContainerType( {typeName: "Attestation", jsonCase: "eth2"} ); +// New type in ELECTRA +export const SingleAttestation = new ContainerType( + { + committeeIndex: CommitteeIndex, + attesterIndex: ValidatorIndex, + data: phase0Ssz.AttestationData, + signature: BLSSignature, + }, + {typeName: "SingleAttestation", jsonCase: "eth2"} +); + export const IndexedAttestation = new ContainerType( { attestingIndices: AttestingIndices, // Modified in ELECTRA diff --git a/packages/types/src/electra/types.ts b/packages/types/src/electra/types.ts index 691de409ed9..ee7585692d4 100644 --- a/packages/types/src/electra/types.ts +++ b/packages/types/src/electra/types.ts @@ -2,6 +2,7 @@ import {ValueOf} from "@chainsafe/ssz"; import * as ssz from "./sszTypes.js"; export type Attestation = ValueOf; +export type SingleAttestation = ValueOf; export type IndexedAttestation = ValueOf; export type IndexedAttestationBigint = ValueOf; export type AttesterSlashing = ValueOf; diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index badb1f1f233..ce54c2f7ae6 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -56,6 +56,7 @@ type TypesByFork = { BeaconState: phase0.BeaconState; SignedBeaconBlock: phase0.SignedBeaconBlock; Metadata: phase0.Metadata; + SingleAttestation: phase0.Attestation; Attestation: phase0.Attestation; IndexedAttestation: phase0.IndexedAttestation; IndexedAttestationBigint: phase0.IndexedAttestationBigint; @@ -79,6 +80,7 @@ type TypesByFork = { LightClientStore: altair.LightClientStore; SyncCommittee: altair.SyncCommittee; SyncAggregate: altair.SyncAggregate; + SingleAttestation: phase0.Attestation; Attestation: phase0.Attestation; IndexedAttestation: phase0.IndexedAttestation; IndexedAttestationBigint: phase0.IndexedAttestationBigint; @@ -110,6 +112,7 @@ type TypesByFork = { SSEPayloadAttributes: bellatrix.SSEPayloadAttributes; SyncCommittee: altair.SyncCommittee; SyncAggregate: altair.SyncAggregate; + SingleAttestation: phase0.Attestation; Attestation: phase0.Attestation; IndexedAttestation: phase0.IndexedAttestation; IndexedAttestationBigint: phase0.IndexedAttestationBigint; @@ -141,6 +144,7 @@ type TypesByFork = { SSEPayloadAttributes: capella.SSEPayloadAttributes; SyncCommittee: altair.SyncCommittee; SyncAggregate: altair.SyncAggregate; + SingleAttestation: phase0.Attestation; Attestation: phase0.Attestation; IndexedAttestation: phase0.IndexedAttestation; IndexedAttestationBigint: phase0.IndexedAttestationBigint; @@ -177,6 +181,7 @@ type TypesByFork = { Contents: deneb.Contents; SyncCommittee: altair.SyncCommittee; SyncAggregate: altair.SyncAggregate; + SingleAttestation: phase0.Attestation; Attestation: phase0.Attestation; IndexedAttestation: phase0.IndexedAttestation; IndexedAttestationBigint: phase0.IndexedAttestationBigint; @@ -213,6 +218,7 @@ type TypesByFork = { Contents: deneb.Contents; SyncCommittee: altair.SyncCommittee; SyncAggregate: altair.SyncAggregate; + SingleAttestation: electra.SingleAttestation; Attestation: electra.Attestation; IndexedAttestation: electra.IndexedAttestation; IndexedAttestationBigint: electra.IndexedAttestationBigint; @@ -281,6 +287,7 @@ export type SignedBuilderBid = TypesByF export type SSEPayloadAttributes = TypesByFork[F]["SSEPayloadAttributes"]; export type Attestation = TypesByFork[F]["Attestation"]; +export type SingleAttestation = TypesByFork[F]["SingleAttestation"]; export type IndexedAttestation = TypesByFork[F]["IndexedAttestation"]; export type IndexedAttestationBigint = TypesByFork[F]["IndexedAttestationBigint"]; export type AttesterSlashing = TypesByFork[F]["AttesterSlashing"]; diff --git a/packages/types/src/utils/typeguards.ts b/packages/types/src/utils/typeguards.ts index d4a27e23d1d..1a59dab6bfe 100644 --- a/packages/types/src/utils/typeguards.ts +++ b/packages/types/src/utils/typeguards.ts @@ -15,6 +15,7 @@ import { SignedBeaconBlockOrContents, SignedBlindedBeaconBlock, SignedBlockContents, + SingleAttestation, } from "../types.js"; export function isExecutionPayload( @@ -73,6 +74,12 @@ export function isElectraAttestation(attestation: Attestation): attestation is A return (attestation as Attestation).committeeBits !== undefined; } +export function isElectraSingleAttestation( + singleAttestation: SingleAttestation +): singleAttestation is SingleAttestation { + return (singleAttestation as SingleAttestation).committeeIndex !== undefined; +} + export function isElectraLightClientUpdate(update: LightClientUpdate): update is LightClientUpdate { const updatePostElectra = update as LightClientUpdate; return ( diff --git a/packages/validator/src/services/attestation.ts b/packages/validator/src/services/attestation.ts index 514ecbbd613..56be02131af 100644 --- a/packages/validator/src/services/attestation.ts +++ b/packages/validator/src/services/attestation.ts @@ -1,8 +1,8 @@ import {ApiClient, routes} from "@lodestar/api"; import {ChainForkConfig} from "@lodestar/config"; -import {ForkSeq} from "@lodestar/params"; +import {ForkPreElectra, ForkSeq} from "@lodestar/params"; import {computeEpochAtSlot, isAggregatorFromCommitteeLength} from "@lodestar/state-transition"; -import {Attestation, BLSSignature, SignedAggregateAndProof, Slot, phase0, ssz} from "@lodestar/types"; +import {BLSSignature, SignedAggregateAndProof, SingleAttestation, Slot, phase0, ssz} from "@lodestar/types"; import {prettyBytes, sleep, toRootHex} from "@lodestar/utils"; import {Metrics} from "../metrics.js"; import {PubkeyHex} from "../types.js"; @@ -193,7 +193,7 @@ export class AttestationService { attestationNoCommittee: phase0.AttestationData, duties: AttDutyAndProof[] ): Promise { - const signedAttestations: Attestation[] = []; + const signedAttestations: SingleAttestation[] = []; const headRootHex = toRootHex(attestationNoCommittee.beaconBlockRoot); const currentEpoch = computeEpochAtSlot(slot); const isPostElectra = currentEpoch >= this.config.ELECTRA_FORK_EPOCH; @@ -239,7 +239,11 @@ export class AttestationService { if (isPostElectra) { (await this.api.beacon.submitPoolAttestationsV2({signedAttestations})).assertOk(); } else { - (await this.api.beacon.submitPoolAttestations({signedAttestations})).assertOk(); + ( + await this.api.beacon.submitPoolAttestations({ + signedAttestations: signedAttestations as SingleAttestation[], + }) + ).assertOk(); } this.logger.info("Published attestations", { ...logCtx, diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 0c66f576ffa..5b3714f1754 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -13,7 +13,6 @@ import { DOMAIN_SYNC_COMMITTEE, DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, ForkSeq, - MAX_COMMITTEES_PER_SLOT, } from "@lodestar/params"; import { ZERO_HASH, @@ -35,6 +34,7 @@ import { SignedAggregateAndProof, SignedBeaconBlock, SignedBlindedBeaconBlock, + SingleAttestation, Slot, ValidatorIndex, altair, @@ -501,7 +501,7 @@ export class ValidatorStore { duty: routes.validator.AttesterDuty, attestationData: phase0.AttestationData, currentEpoch: Epoch - ): Promise { + ): Promise { // Make sure the target epoch is not higher than the current epoch to avoid potential attacks. if (attestationData.target.epoch > currentEpoch) { throw Error( @@ -535,10 +535,10 @@ export class ValidatorStore { if (this.config.getForkSeq(signingSlot) >= ForkSeq.electra) { return { - aggregationBits: BitArray.fromSingleBit(duty.committeeLength, duty.validatorCommitteeIndex), + committeeIndex: duty.committeeIndex, + attesterIndex: duty.validatorIndex, data: attestationData, signature: await this.getSignature(duty.pubkey, signingRoot, signingSlot, signableMessage), - committeeBits: BitArray.fromSingleBit(MAX_COMMITTEES_PER_SLOT, duty.committeeIndex), }; } diff --git a/packages/validator/test/unit/services/attestation.test.ts b/packages/validator/test/unit/services/attestation.test.ts index e3a6f0771d6..f8aee27d0dc 100644 --- a/packages/validator/test/unit/services/attestation.test.ts +++ b/packages/validator/test/unit/services/attestation.test.ts @@ -81,17 +81,20 @@ describe("AttestationService", () => { opts ); - const attestation = isPostElectra + const singleAttestation = isPostElectra + ? ssz.electra.SingleAttestation.defaultValue() + : ssz.phase0.Attestation.defaultValue(); + const aggregatedAttestation = isPostElectra ? ssz.electra.Attestation.defaultValue() : ssz.phase0.Attestation.defaultValue(); - const aggregate = isPostElectra + const aggregateAndProof = isPostElectra ? ssz.electra.SignedAggregateAndProof.defaultValue() : ssz.phase0.SignedAggregateAndProof.defaultValue(); const duties: AttDutyAndProof[] = [ { duty: { slot: 0, - committeeIndex: attestation.data.index, + committeeIndex: singleAttestation.data.index, committeeLength: 120, committeesAtSlot: 120, validatorCommitteeIndex: 1, @@ -115,15 +118,15 @@ describe("AttestationService", () => { vi.spyOn(attestationService["dutiesService"], "getDutiesAtSlot").mockImplementation(() => duties); // Mock beacon's attestation and aggregates endpoints - api.validator.produceAttestationData.mockResolvedValue(mockApiResponse({data: attestation.data})); + api.validator.produceAttestationData.mockResolvedValue(mockApiResponse({data: singleAttestation.data})); if (isPostElectra) { api.validator.getAggregatedAttestationV2.mockResolvedValue( - mockApiResponse({data: attestation, meta: {version: ForkName.electra}}) + mockApiResponse({data: aggregatedAttestation, meta: {version: ForkName.electra}}) ); api.beacon.submitPoolAttestationsV2.mockResolvedValue(mockApiResponse({})); api.validator.publishAggregateAndProofsV2.mockResolvedValue(mockApiResponse({})); } else { - api.validator.getAggregatedAttestation.mockResolvedValue(mockApiResponse({data: attestation})); + api.validator.getAggregatedAttestation.mockResolvedValue(mockApiResponse({data: aggregatedAttestation})); api.beacon.submitPoolAttestations.mockResolvedValue(mockApiResponse({})); api.validator.publishAggregateAndProofs.mockResolvedValue(mockApiResponse({})); } @@ -139,8 +142,8 @@ describe("AttestationService", () => { } // Mock signing service - validatorStore.signAttestation.mockResolvedValue(attestation); - validatorStore.signAggregateAndProof.mockResolvedValue(aggregate); + validatorStore.signAttestation.mockResolvedValue(singleAttestation); + validatorStore.signAggregateAndProof.mockResolvedValue(aggregateAndProof); // Trigger clock onSlot for slot 0 await clock.tickSlotFns(0, controller.signal); @@ -170,21 +173,23 @@ describe("AttestationService", () => { if (isPostElectra) { // Must submit the attestation received through produceAttestationData() expect(api.beacon.submitPoolAttestationsV2).toHaveBeenCalledOnce(); - expect(api.beacon.submitPoolAttestationsV2).toHaveBeenCalledWith({signedAttestations: [attestation]}); + expect(api.beacon.submitPoolAttestationsV2).toHaveBeenCalledWith({signedAttestations: [singleAttestation]}); // Must submit the aggregate received through getAggregatedAttestationV2() then createAndSignAggregateAndProof() expect(api.validator.publishAggregateAndProofsV2).toHaveBeenCalledOnce(); expect(api.validator.publishAggregateAndProofsV2).toHaveBeenCalledWith({ - signedAggregateAndProofs: [aggregate], + signedAggregateAndProofs: [aggregateAndProof], }); } else { // Must submit the attestation received through produceAttestationData() expect(api.beacon.submitPoolAttestations).toHaveBeenCalledOnce(); - expect(api.beacon.submitPoolAttestations).toHaveBeenCalledWith({signedAttestations: [attestation]}); + expect(api.beacon.submitPoolAttestations).toHaveBeenCalledWith({signedAttestations: [singleAttestation]}); // Must submit the aggregate received through getAggregatedAttestation() then createAndSignAggregateAndProof() expect(api.validator.publishAggregateAndProofs).toHaveBeenCalledOnce(); - expect(api.validator.publishAggregateAndProofs).toHaveBeenCalledWith({signedAggregateAndProofs: [aggregate]}); + expect(api.validator.publishAggregateAndProofs).toHaveBeenCalledWith({ + signedAggregateAndProofs: [aggregateAndProof], + }); } }); });