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 cb0f1893b295..faf8e8dbb322 100644 --- a/packages/beacon-node/src/api/impl/beacon/pool/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/pool/index.ts @@ -53,7 +53,7 @@ export function getBeaconPoolApi({ attestations.map(async (attestation, i) => { try { // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const validateFn = () => validateGossipAttestation(chain, attestation, null, null); + const validateFn = () => validateGossipAttestation(chain, {attestation, serializedData: null}, null); const {slot, beaconBlockRoot} = attestation.data; // when a validator is configured with multiple beacon node urls, this attestation data may come from another beacon node // and the block hasn't been in our forkchoice since we haven't seen / processing that block diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index f08da9c7add3..e32b890ce2e4 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -493,6 +493,12 @@ export class BeaconChain implements IBeaconChain { } } + persistInvalidSszBytes(typeName: string, sszBytes: Uint8Array, suffix?: string): void { + if (this.opts.persistInvalidSszObjects) { + void this.persistInvalidSszObject(typeName, sszBytes, sszBytes, suffix); + } + } + persistInvalidSszView(view: TreeView, suffix?: string): void { if (this.opts.persistInvalidSszObjects) { void this.persistInvalidSszObject(view.type.typeName, view.serialize(), view.hashTreeRoot(), suffix); diff --git a/packages/beacon-node/src/chain/errors/attestationError.ts b/packages/beacon-node/src/chain/errors/attestationError.ts index 7e430db758db..bb907eda5834 100644 --- a/packages/beacon-node/src/chain/errors/attestationError.ts +++ b/packages/beacon-node/src/chain/errors/attestationError.ts @@ -126,6 +126,10 @@ export enum AttestationErrorCode { * Invalid attestation indexes: not sorted or unique */ INVALID_INDEXED_ATTESTATION = "ATTESTATION_ERROR_INVALID_INDEXED_ATTESTATION", + /** + * Invalid ssz bytes. + */ + INVALID_SERIALIZED_BYTES = "ATTESTATION_ERROR_INVALID_SERIALIZED_BYTES", } export type AttestationErrorType = @@ -158,7 +162,8 @@ export type AttestationErrorType = | {code: AttestationErrorCode.COMMITTEE_INDEX_OUT_OF_RANGE; index: number} | {code: AttestationErrorCode.MISSING_ATTESTATION_HEAD_STATE; error: Error} | {code: AttestationErrorCode.INVALID_AGGREGATOR} - | {code: AttestationErrorCode.INVALID_INDEXED_ATTESTATION}; + | {code: AttestationErrorCode.INVALID_INDEXED_ATTESTATION} + | {code: AttestationErrorCode.INVALID_SERIALIZED_BYTES}; export class AttestationError extends GossipActionError { getMetadata(): Record { diff --git a/packages/beacon-node/src/chain/errors/gossipValidation.ts b/packages/beacon-node/src/chain/errors/gossipValidation.ts index fbf9fd7086d6..c61c2b32b05d 100644 --- a/packages/beacon-node/src/chain/errors/gossipValidation.ts +++ b/packages/beacon-node/src/chain/errors/gossipValidation.ts @@ -5,6 +5,8 @@ export enum GossipAction { REJECT = "REJECT", } +export const INVALID_SERIALIZED_BYTES_ERROR_CODE = "GOSSIP_ERROR_INVALID_SERIALIZED_BYTES"; + export class GossipActionError extends LodestarError { action: GossipAction; diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 80ef3a2cf938..990cfda1ea9c 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -134,6 +134,7 @@ export interface IBeaconChain { updateBeaconProposerData(epoch: Epoch, proposers: ProposerPreparationData[]): Promise; persistInvalidSszValue(type: Type, sszObject: T | Uint8Array, suffix?: string): void; + persistInvalidSszBytes(type: string, sszBytes: Uint8Array, suffix?: string): void; /** Persist bad items to persistInvalidSszObjectsDir dir, for example invalid state, attestations etc. */ persistInvalidSszView(view: TreeView, suffix?: string): void; updateBuilderStatus(clockSlot: Slot): void; diff --git a/packages/beacon-node/src/chain/seenCache/seenAttestationData.ts b/packages/beacon-node/src/chain/seenCache/seenAttestationData.ts index a3e083f2b041..d6eacdbf0d11 100644 --- a/packages/beacon-node/src/chain/seenCache/seenAttestationData.ts +++ b/packages/beacon-node/src/chain/seenCache/seenAttestationData.ts @@ -1,4 +1,4 @@ -import {RootHex, Slot} from "@lodestar/types"; +import {phase0, RootHex, Slot} from "@lodestar/types"; import {MapDef} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; import {AttDataBase64} from "../../util/sszBytes.js"; @@ -9,8 +9,11 @@ export type AttestationDataCacheEntry = { committeeIndices: number[]; // IndexedAttestationData signing root, 32 bytes signingRoot: Uint8Array; - // to be consumed by forkchoice + // to be consumed by forkchoice and oppool attDataRootHex: RootHex; + // caching this for 3 slots take 600 instances max, this is nothing compared to attestations processed per slot + // for example in a mainnet node subscribing to all subnets, attestations are processed up to 20k per slot + attestationData: phase0.AttestationData; subnet: number; }; diff --git a/packages/beacon-node/src/chain/validation/attestation.ts b/packages/beacon-node/src/chain/validation/attestation.ts index 97e9947fda72..fce591d29f8f 100644 --- a/packages/beacon-node/src/chain/validation/attestation.ts +++ b/packages/beacon-node/src/chain/validation/attestation.ts @@ -13,21 +13,42 @@ import {IBeaconChain} from ".."; import {AttestationError, AttestationErrorCode, GossipAction} from "../errors/index.js"; import {MAXIMUM_GOSSIP_CLOCK_DISPARITY_SEC} from "../../constants/index.js"; import {RegenCaller} from "../regen/index.js"; -import {getAttDataBase64FromAttestationSerialized} from "../../util/sszBytes.js"; +import { + AttDataBase64, + getAggregationBitsFromAttestationSerialized, + getAttDataBase64FromAttestationSerialized, + getSignatureFromAttestationSerialized, +} from "../../util/sszBytes.js"; +import {AttestationDataCacheEntry} from "../seenCache/seenAttestationData.js"; +import {sszDeserializeAttestation} from "../../network/gossip/topic.js"; export type AttestationValidationResult = { + attestation: phase0.Attestation; indexedAttestation: phase0.IndexedAttestation; subnet: number; attDataRootHex: RootHex; }; +export type AttestationOrBytes = + // for api + | {attestation: phase0.Attestation; serializedData: null} + // for gossip + | { + attestation: null; + serializedData: Uint8Array; + // available in NetworkProcessor since we check for unknown block root attestations + attSlot: Slot; + }; + +/** + * Only deserialize the attestation if needed, use the cached AttestationData instead + * This is to avoid deserializing similar attestation multiple times which could help the gc + */ export async function validateGossipAttestation( chain: IBeaconChain, - attestation: phase0.Attestation, + attestationOrBytes: AttestationOrBytes, /** Optional, to allow verifying attestations through API with unknown subnet */ - subnet: number | null, - // available for gossip attestations, null for api attestations - serializedData: Uint8Array | null = null + subnet: number | null ): Promise { // Do checks in this order: // - do early checks (w/o indexed attestation) @@ -38,17 +59,39 @@ export async function validateGossipAttestation( // verify_early_checks // Run the checks that happen before an indexed attestation is constructed. - const attData = attestation.data; + + let attestationOrCache: + | {attestation: phase0.Attestation; cache: null} + | {attestation: null; cache: AttestationDataCacheEntry; serializedData: Uint8Array}; + let attDataBase64: AttDataBase64 | null; + if (attestationOrBytes.serializedData) { + // gossip + attDataBase64 = getAttDataBase64FromAttestationSerialized(attestationOrBytes.serializedData); + const attSlot = attestationOrBytes.attSlot; + const cachedAttData = attDataBase64 !== null ? chain.seenAttestationDatas.get(attSlot, attDataBase64) : null; + if (cachedAttData === null) { + const attestation = sszDeserializeAttestation(attestationOrBytes.serializedData); + // only deserialize on the first AttestationData that's not cached + attestationOrCache = {attestation, cache: null}; + } else { + attestationOrCache = {attestation: null, cache: cachedAttData, serializedData: attestationOrBytes.serializedData}; + } + } else { + // api + attDataBase64 = null; + attestationOrCache = {attestation: attestationOrBytes.attestation, cache: null}; + } + + const attData: phase0.AttestationData = attestationOrCache.attestation + ? attestationOrCache.attestation.data + : attestationOrCache.cache.attestationData; const attSlot = attData.slot; const attIndex = attData.index; const attEpoch = computeEpochAtSlot(attSlot); const attTarget = attData.target; const targetEpoch = attTarget.epoch; - const attDataBase64 = serializedData ? getAttDataBase64FromAttestationSerialized(serializedData) : null; - const cachedAttData = attDataBase64 ? chain.seenAttestationDatas.get(attSlot, attDataBase64) : null; - - if (!cachedAttData) { + if (!attestationOrCache.cache) { // [REJECT] The attestation's epoch matches its target -- i.e. attestation.data.target.epoch == compute_epoch_at_slot(attestation.data.slot) if (targetEpoch !== attEpoch) { throw new AttestationError(GossipAction.REJECT, { @@ -59,13 +102,21 @@ export async function validateGossipAttestation( // [IGNORE] attestation.data.slot is within the last ATTESTATION_PROPAGATION_SLOT_RANGE slots (within a MAXIMUM_GOSSIP_CLOCK_DISPARITY allowance) // -- i.e. attestation.data.slot + ATTESTATION_PROPAGATION_SLOT_RANGE >= current_slot >= attestation.data.slot // (a client MAY queue future attestations for processing at the appropriate slot). - verifyPropagationSlotRange(chain, attSlot); + verifyPropagationSlotRange(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 = attestation.aggregationBits; + const aggregationBits = attestationOrCache.attestation + ? attestationOrCache.attestation.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, { @@ -76,10 +127,11 @@ export async function validateGossipAttestation( let committeeIndices: number[]; let getSigningRoot: () => Uint8Array; let expectedSubnet: number; - if (cachedAttData) { - committeeIndices = cachedAttData.committeeIndices; - getSigningRoot = () => cachedAttData.signingRoot; - expectedSubnet = cachedAttData.subnet; + if (attestationOrCache.cache) { + committeeIndices = attestationOrCache.cache.committeeIndices; + const signingRoot = attestationOrCache.cache.signingRoot; + getSigningRoot = () => signingRoot; + expectedSubnet = attestationOrCache.cache.subnet; } else { // Attestations must be for a known block. If the block is unknown, we simply drop the // attestation and do not delay consideration for later. @@ -88,7 +140,12 @@ export async function validateGossipAttestation( // [IGNORE] The block being voted for (attestation.data.beacon_block_root) has been seen (via both gossip // and non-gossip sources) (a client MAY queue attestations for processing once block is retrieved). - const attHeadBlock = verifyHeadBlockAndTargetRoot(chain, attData.beaconBlockRoot, attTarget.root, attEpoch); + const attHeadBlock = verifyHeadBlockAndTargetRoot( + chain, + attestationOrCache.attestation.data.beaconBlockRoot, + attestationOrCache.attestation.data.target.root, + attEpoch + ); // [REJECT] The block being voted for (attestation.data.beacon_block_root) passes validation. // > Altready check in `verifyHeadBlockAndTargetRoot()` @@ -162,19 +219,28 @@ export async function validateGossipAttestation( const attestingIndices = [validatorIndex]; let signatureSet: ISignatureSet; let attDataRootHex: RootHex; - if (cachedAttData) { + const signature = attestationOrCache.attestation + ? attestationOrCache.attestation.signature + : getSignatureFromAttestationSerialized(attestationOrCache.serializedData); + if (signature === null) { + throw new AttestationError(GossipAction.REJECT, { + code: AttestationErrorCode.INVALID_SERIALIZED_BYTES, + }); + } + + 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 = createAggregateSignatureSetFromComponents( attestingIndices.map((i) => chain.index2pubkey[i]), - cachedAttData.signingRoot, - attestation.signature + attestationOrCache.cache.signingRoot, + signature ); - attDataRootHex = cachedAttData.attDataRootHex; + attDataRootHex = attestationOrCache.cache.attDataRootHex; } else { signatureSet = createAggregateSignatureSetFromComponents( attestingIndices.map((i) => chain.index2pubkey[i]), getSigningRoot(), - attestation.signature + signature ); // add cached attestation data before verifying signature @@ -187,6 +253,7 @@ export async function validateGossipAttestation( // precompute this to be used in forkchoice // root of AttestationData was already cached during getIndexedAttestationSignatureSet attDataRootHex, + attestationData: attData, }); } } @@ -213,9 +280,17 @@ export async function validateGossipAttestation( const indexedAttestation: phase0.IndexedAttestation = { attestingIndices, data: attData, - signature: attestation.signature, + signature, }; - return {indexedAttestation, subnet: expectedSubnet, attDataRootHex}; + + const attestation: phase0.Attestation = attestationOrCache.attestation + ? attestationOrCache.attestation + : { + aggregationBits, + data: attData, + signature, + }; + return {attestation, indexedAttestation, subnet: expectedSubnet, attDataRootHex}; } /** diff --git a/packages/beacon-node/src/network/gossip/interface.ts b/packages/beacon-node/src/network/gossip/interface.ts index d0dbd998674e..9b2c5f8511d3 100644 --- a/packages/beacon-node/src/network/gossip/interface.ts +++ b/packages/beacon-node/src/network/gossip/interface.ts @@ -4,7 +4,7 @@ import {Message, TopicValidatorResult} from "@libp2p/interface-pubsub"; import StrictEventEmitter from "strict-event-emitter-types"; import {PeerIdStr} from "@chainsafe/libp2p-gossipsub/types"; import {ForkName} from "@lodestar/params"; -import {allForks, altair, capella, deneb, phase0} from "@lodestar/types"; +import {allForks, altair, capella, deneb, phase0, Slot} from "@lodestar/types"; import {BeaconConfig} from "@lodestar/config"; import {Logger} from "@lodestar/utils"; import {IBeaconChain} from "../../chain/index.js"; @@ -65,6 +65,10 @@ export type GossipTopicMap = { */ export type GossipTopic = GossipTopicMap[keyof GossipTopicMap]; +export type SSZTypeOfGossipTopic = T extends {type: infer K extends GossipType} + ? GossipTypeMap[K] + : never; + export type GossipTypeMap = { [GossipType.beacon_block]: allForks.SignedBeaconBlock; [GossipType.beacon_block_and_blobs_sidecar]: deneb.SignedBeaconBlockAndBlobsSidecar; @@ -152,7 +156,8 @@ export type GossipValidatorFn = ( topic: GossipTopic, msg: Message, propagationSource: PeerIdStr, - seenTimestampSec: number + seenTimestampSec: number, + msgSlot?: Slot ) => Promise; export type ValidatorFnsByType = {[K in GossipType]: GossipValidatorFn}; @@ -161,20 +166,24 @@ export type GossipJobQueues = { [K in GossipType]: JobItemQueue, ResolvedType>; }; +export type GossipData = { + serializedData: Uint8Array; + msgSlot?: Slot; +}; + export type GossipHandlerFn = ( - object: GossipTypeMap[GossipType], + gossipData: GossipData, topic: GossipTopicMap[GossipType], peerIdStr: string, - seenTimestampSec: number, - gossipSerializedData: Uint8Array + seenTimestampSec: number ) => Promise; + export type GossipHandlers = { [K in GossipType]: ( - object: GossipTypeMap[K], + gossipData: GossipData, topic: GossipTopicMap[K], peerIdStr: string, - seenTimestampSec: number, - gossipSerializedData: Uint8Array + seenTimestampSec: number ) => Promise; }; diff --git a/packages/beacon-node/src/network/gossip/topic.ts b/packages/beacon-node/src/network/gossip/topic.ts index bc3491990415..77e02d670975 100644 --- a/packages/beacon-node/src/network/gossip/topic.ts +++ b/packages/beacon-node/src/network/gossip/topic.ts @@ -1,4 +1,4 @@ -import {ssz} from "@lodestar/types"; +import {phase0, ssz} from "@lodestar/types"; import {ForkDigestContext} from "@lodestar/config"; import { ATTESTATION_SUBNET_COUNT, @@ -8,7 +8,12 @@ import { isForkLightClient, } from "@lodestar/params"; -import {GossipEncoding, GossipTopic, GossipType, GossipTopicTypeMap} from "./interface.js"; +import { + GossipAction, + GossipActionError, + INVALID_SERIALIZED_BYTES_ERROR_CODE, +} from "../../chain/errors/gossipValidation.js"; +import {GossipEncoding, GossipTopic, GossipType, GossipTopicTypeMap, SSZTypeOfGossipTopic} from "./interface.js"; import {DEFAULT_ENCODING} from "./constants.js"; export interface IGossipTopicCache { @@ -110,6 +115,29 @@ export function getGossipSSZType(topic: GossipTopic) { } } +/** + * Deserialize a gossip serialized data into an ssz object. + */ +export function sszDeserialize(topic: T, serializedData: Uint8Array): SSZTypeOfGossipTopic { + const sszType = getGossipSSZType(topic); + try { + return sszType.deserialize(serializedData) as SSZTypeOfGossipTopic; + } catch (e) { + throw new GossipActionError(GossipAction.REJECT, {code: INVALID_SERIALIZED_BYTES_ERROR_CODE}); + } +} + +/** + * Deserialize a gossip serialized data into an Attestation object. + */ +export function sszDeserializeAttestation(serializedData: Uint8Array): phase0.Attestation { + try { + return ssz.phase0.Attestation.deserialize(serializedData); + } catch (e) { + throw new GossipActionError(GossipAction.REJECT, {code: INVALID_SERIALIZED_BYTES_ERROR_CODE}); + } +} + // Parsing const gossipTopicRegex = new RegExp("^/eth2/(\\w+)/(\\w+)/(\\w+)"); diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 8cf01e62d81f..998401b6a106 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -38,6 +38,7 @@ import {validateLightClientOptimisticUpdate} from "../../chain/validation/lightC import {validateGossipBlobsSidecar} from "../../chain/validation/blobsSidecar.js"; import {BlockInput, getBlockInput} from "../../chain/blocks/types.js"; import {AttnetsService} from "../subnets/attnetsService.js"; +import {sszDeserialize} from "../gossip/topic.js"; /** * Gossip handler options as part of network options @@ -168,7 +169,8 @@ export function getGossipHandlers(modules: ValidatorFnsModules, options: GossipH } return { - [GossipType.beacon_block]: async (signedBlock, topic, peerIdStr, seenTimestampSec) => { + [GossipType.beacon_block]: async ({serializedData}, topic, peerIdStr, seenTimestampSec) => { + const signedBlock = sszDeserialize(topic, serializedData); // TODO Deneb: Can blocks be received by this topic? if (config.getForkSeq(signedBlock.message.slot) >= ForkSeq.deneb) { throw new GossipActionError(GossipAction.REJECT, {code: "POST_DENEB_BLOCK"}); @@ -179,7 +181,8 @@ export function getGossipHandlers(modules: ValidatorFnsModules, options: GossipH handleValidBeaconBlock(blockInput, peerIdStr, seenTimestampSec); }, - [GossipType.beacon_block_and_blobs_sidecar]: async (blockAndBlocks, topic, peerIdStr, seenTimestampSec) => { + [GossipType.beacon_block_and_blobs_sidecar]: async ({serializedData}, topic, peerIdStr, seenTimestampSec) => { + const blockAndBlocks = sszDeserialize(topic, serializedData); const {beaconBlock, blobsSidecar} = blockAndBlocks; // TODO Deneb: Should throw for pre fork blocks? if (config.getForkSeq(beaconBlock.message.slot) < ForkSeq.deneb) { @@ -193,25 +196,12 @@ export function getGossipHandlers(modules: ValidatorFnsModules, options: GossipH handleValidBeaconBlock(blockInput, peerIdStr, seenTimestampSec); }, - [GossipType.beacon_aggregate_and_proof]: async ( - signedAggregateAndProof, - _topic, - _peer, - seenTimestampSec, - gossipSerializedData - ) => { + [GossipType.beacon_aggregate_and_proof]: async ({serializedData}, topic, _peer, seenTimestampSec) => { let validationResult: AggregateAndProofValidationResult; + const signedAggregateAndProof = sszDeserialize(topic, serializedData); try { - // If an attestation refers to a block root that's not known, it will wait for 1 slot max - // See https://github.com/ChainSafe/lodestar/pull/3564 for reasoning and results - // Waiting here requires minimal code and automatically affects attestation, and aggregate validation - // both from gossip and the API. I also prevents having to catch and re-throw in multiple places. - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const validateFn = () => - validateGossipAggregateAndProof(chain, signedAggregateAndProof, false, gossipSerializedData); - const {slot, beaconBlockRoot} = signedAggregateAndProof.message.aggregate.data; - validationResult = await validateGossipFnRetryUnknownRoot(validateFn, chain, slot, beaconBlockRoot); + validationResult = await validateGossipAggregateAndProof(chain, signedAggregateAndProof, false, serializedData); } catch (e) { if (e instanceof AttestationError && e.action === GossipAction.REJECT) { chain.persistInvalidSszValue(ssz.phase0.SignedAggregateAndProof, signedAggregateAndProof, "gossip_reject"); @@ -244,32 +234,33 @@ export function getGossipHandlers(modules: ValidatorFnsModules, options: GossipH } }, - [GossipType.beacon_attestation]: async (attestation, {subnet}, _peer, seenTimestampSec, gossipSerializedData) => { + [GossipType.beacon_attestation]: async ({serializedData, msgSlot}, {subnet}, _peer, seenTimestampSec) => { + if (msgSlot === undefined) { + throw Error("msgSlot is undefined for beacon_attestation topic"); + } + // do not deserialize gossipSerializedData here, it's done in validateGossipAttestation only if needed let validationResult: AttestationValidationResult; try { - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const validateFn = () => validateGossipAttestation(chain, attestation, subnet, gossipSerializedData); - const {slot, beaconBlockRoot} = attestation.data; - // If an attestation refers to a block root that's not known, it will wait for 1 slot max - // See https://github.com/ChainSafe/lodestar/pull/3564 for reasoning and results - // Waiting here requires minimal code and automatically affects attestation, and aggregate validation - // both from gossip and the API. I also prevents having to catch and re-throw in multiple places. - validationResult = await validateGossipFnRetryUnknownRoot(validateFn, chain, slot, beaconBlockRoot); + validationResult = await validateGossipAttestation( + chain, + {attestation: null, serializedData, attSlot: msgSlot}, + subnet + ); } catch (e) { if (e instanceof AttestationError && e.action === GossipAction.REJECT) { - chain.persistInvalidSszValue(ssz.phase0.Attestation, attestation, "gossip_reject"); + chain.persistInvalidSszBytes(ssz.phase0.Attestation.typeName, serializedData, "gossip_reject"); } throw e; } // Handler - const {indexedAttestation, attDataRootHex} = validationResult; + const {indexedAttestation, attDataRootHex, attestation} = validationResult; metrics?.registerGossipUnaggregatedAttestation(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 (attnetsService.shouldProcess(subnet, attestation.data.slot)) { + if (attnetsService.shouldProcess(subnet, indexedAttestation.data.slot)) { const insertOutcome = chain.attestationPool.add(attestation, attDataRootHex); metrics?.opPool.attestationPoolInsertOutcome.inc({insertOutcome}); } @@ -286,7 +277,8 @@ export function getGossipHandlers(modules: ValidatorFnsModules, options: GossipH } }, - [GossipType.attester_slashing]: async (attesterSlashing) => { + [GossipType.attester_slashing]: async ({serializedData}, topic) => { + const attesterSlashing = sszDeserialize(topic, serializedData); await validateGossipAttesterSlashing(chain, attesterSlashing); // Handler @@ -299,7 +291,8 @@ export function getGossipHandlers(modules: ValidatorFnsModules, options: GossipH } }, - [GossipType.proposer_slashing]: async (proposerSlashing) => { + [GossipType.proposer_slashing]: async ({serializedData}, topic) => { + const proposerSlashing = sszDeserialize(topic, serializedData); await validateGossipProposerSlashing(chain, proposerSlashing); // Handler @@ -311,7 +304,8 @@ export function getGossipHandlers(modules: ValidatorFnsModules, options: GossipH } }, - [GossipType.voluntary_exit]: async (voluntaryExit) => { + [GossipType.voluntary_exit]: async ({serializedData}, topic) => { + const voluntaryExit = sszDeserialize(topic, serializedData); await validateGossipVoluntaryExit(chain, voluntaryExit); // Handler @@ -323,7 +317,8 @@ export function getGossipHandlers(modules: ValidatorFnsModules, options: GossipH } }, - [GossipType.sync_committee_contribution_and_proof]: async (contributionAndProof) => { + [GossipType.sync_committee_contribution_and_proof]: async ({serializedData}, topic) => { + const contributionAndProof = sszDeserialize(topic, serializedData); const {syncCommitteeParticipantIndices} = await validateSyncCommitteeGossipContributionAndProof( chain, contributionAndProof @@ -344,7 +339,9 @@ export function getGossipHandlers(modules: ValidatorFnsModules, options: GossipH } }, - [GossipType.sync_committee]: async (syncCommittee, {subnet}) => { + [GossipType.sync_committee]: async ({serializedData}, topic) => { + const syncCommittee = sszDeserialize(topic, serializedData); + const {subnet} = topic; let indexInSubcommittee = 0; try { indexInSubcommittee = (await validateGossipSyncCommittee(chain, syncCommittee, subnet)).indexInSubcommittee; @@ -365,16 +362,19 @@ export function getGossipHandlers(modules: ValidatorFnsModules, options: GossipH } }, - [GossipType.light_client_finality_update]: async (lightClientFinalityUpdate) => { + [GossipType.light_client_finality_update]: async ({serializedData}, topic) => { + const lightClientFinalityUpdate = sszDeserialize(topic, serializedData); validateLightClientFinalityUpdate(config, chain, lightClientFinalityUpdate); }, - [GossipType.light_client_optimistic_update]: async (lightClientOptimisticUpdate) => { + [GossipType.light_client_optimistic_update]: async ({serializedData}, topic) => { + const lightClientOptimisticUpdate = sszDeserialize(topic, serializedData); validateLightClientOptimisticUpdate(config, chain, lightClientOptimisticUpdate); }, // blsToExecutionChange is to be generated and validated against GENESIS_FORK_VERSION - [GossipType.bls_to_execution_change]: async (blsToExecutionChange, _topic) => { + [GossipType.bls_to_execution_change]: async ({serializedData}, topic) => { + const blsToExecutionChange = sszDeserialize(topic, serializedData); await validateBlsToExecutionChange(chain, blsToExecutionChange); // Handler diff --git a/packages/beacon-node/src/network/processor/gossipValidatorFn.ts b/packages/beacon-node/src/network/processor/gossipValidatorFn.ts index 31b5ed175a3c..0767a5b03cea 100644 --- a/packages/beacon-node/src/network/processor/gossipValidatorFn.ts +++ b/packages/beacon-node/src/network/processor/gossipValidatorFn.ts @@ -2,7 +2,6 @@ import {TopicValidatorResult} from "@libp2p/interface-pubsub"; import {ChainForkConfig} from "@lodestar/config"; import {Logger} from "@lodestar/utils"; import {Metrics} from "../../metrics/index.js"; -import {getGossipSSZType} from "../gossip/topic.js"; import {GossipValidatorFn, GossipHandlers, GossipHandlerFn} from "../gossip/interface.js"; import {GossipActionError, GossipAction} from "../../chain/errors/index.js"; @@ -29,27 +28,15 @@ export type ValidatorFnModules = { export function getGossipValidatorFn(gossipHandlers: GossipHandlers, modules: ValidatorFnModules): GossipValidatorFn { const {logger, metrics} = modules; - return async function gossipValidatorFn(topic, msg, propagationSource, seenTimestampSec) { + return async function gossipValidatorFn(topic, msg, propagationSource, seenTimestampSec, msgSlot) { const type = topic.type; - // Define in scope above try {} to be used in catch {} if object was parsed - let gossipObject; try { - // Deserialize object from bytes ONLY after being picked up from the validation queue - try { - const sszType = getGossipSSZType(topic); - gossipObject = sszType.deserialize(msg.data); - } catch (e) { - // TODO: Log the error or do something better with it - return TopicValidatorResult.Reject; - } - await (gossipHandlers[type] as GossipHandlerFn)( - gossipObject, + {serializedData: msg.data, msgSlot}, topic, propagationSource, - seenTimestampSec, - msg.data + seenTimestampSec ); metrics?.gossipValidationAccept.inc({topic: type}); @@ -73,6 +60,7 @@ export function getGossipValidatorFn(gossipHandlers: GossipHandlers, modules: Va case GossipAction.REJECT: metrics?.gossipValidationReject.inc({topic: type}); + logger.debug(`Gossip validation ${type} rejected`, {}, e); return TopicValidatorResult.Reject; } } diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index dbfac44256f9..a20445a8dadd 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -169,18 +169,24 @@ export class NetworkProcessor { const slotRoot = extractBlockSlotRootFn(message.msg.data); // 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 && !this.chain.forkChoice.hasBlockHex(slotRoot.root)) { - if (this.unknownBlockGossipsubMessagesCount > MAX_QUEUED_UNKNOWN_BLOCK_GOSSIP_OBJECTS) { - // TODO: Should report the dropped job to gossip? It will be eventually pruned from the mcache - this.metrics?.reprocessGossipAttestations.reject.inc({reason: ReprocessRejectReason.reached_limit}); + if (slotRoot) { + // msgSlot is only available for beacon_attestation and aggregate_and_proof + const {slot, root} = slotRoot; + message.msgSlot = slot; + if (!this.chain.forkChoice.hasBlockHex(root)) { + if (this.unknownBlockGossipsubMessagesCount > MAX_QUEUED_UNKNOWN_BLOCK_GOSSIP_OBJECTS) { + // TODO: Should report the dropped job to gossip? It will be eventually pruned from the mcache + this.metrics?.reprocessGossipAttestations.reject.inc({reason: ReprocessRejectReason.reached_limit}); + return; + } + + this.metrics?.reprocessGossipAttestations.total.inc(); + const awaitingGossipsubMessagesByRoot = this.awaitingGossipsubMessagesByRootBySlot.getOrDefault(slot); + const awaitingGossipsubMessages = awaitingGossipsubMessagesByRoot.getOrDefault(root); + awaitingGossipsubMessages.add(message); + this.unknownBlockGossipsubMessagesCount++; return; } - - this.metrics?.reprocessGossipAttestations.total.inc(); - const awaitingGossipsubMessagesByRoot = this.awaitingGossipsubMessagesByRootBySlot.getOrDefault(slotRoot.slot); - const awaitingGossipsubMessages = awaitingGossipsubMessagesByRoot.getOrDefault(slotRoot.root); - awaitingGossipsubMessages.add(message); - this.unknownBlockGossipsubMessagesCount++; } } diff --git a/packages/beacon-node/src/network/processor/types.ts b/packages/beacon-node/src/network/processor/types.ts index 660986ead1c0..eaea872c90c6 100644 --- a/packages/beacon-node/src/network/processor/types.ts +++ b/packages/beacon-node/src/network/processor/types.ts @@ -1,6 +1,6 @@ import {PeerId} from "@libp2p/interface-peer-id"; import {Message} from "@libp2p/interface-pubsub"; -import {SlotRootHex} from "@lodestar/types"; +import {Slot, SlotRootHex} from "@lodestar/types"; import {GossipTopic, GossipType} from "../gossip/index.js"; export type GossipAttestationsWork = { @@ -10,6 +10,8 @@ export type GossipAttestationsWork = { export type PendingGossipsubMessage = { topic: GossipTopic; msg: Message; + // only available for beacon_attestation and aggregate_and_proof + msgSlot?: Slot; msgId: string; // TODO: Refactor into accepting string (requires gossipsub changes) for easier multi-threading propagationSource: PeerId; diff --git a/packages/beacon-node/src/network/processor/worker.ts b/packages/beacon-node/src/network/processor/worker.ts index dc8fb82a6d34..293cd1e6923e 100644 --- a/packages/beacon-node/src/network/processor/worker.ts +++ b/packages/beacon-node/src/network/processor/worker.ts @@ -33,7 +33,8 @@ export class NetworkWorker { message.topic, message.msg, message.propagationSource.toString(), - message.seenTimestampSec + message.seenTimestampSec, + message.msgSlot ); if (message.startProcessUnixSec !== null) { diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index a0ffff0ceca8..82b0972fd39e 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -1,4 +1,5 @@ -import {RootHex, Slot} from "@lodestar/types"; +import {BitArray} from "@chainsafe/ssz"; +import {BLSSignature, RootHex, Slot} from "@lodestar/types"; import {toHex} from "@lodestar/utils"; export type BlockRootHex = RootHex; @@ -6,8 +7,8 @@ export type AttDataBase64 = string; // class Attestation(Container): // aggregation_bits: Bitlist[MAX_VALIDATORS_PER_COMMITTEE] - offset 4 -// data: AttestationData - target data -// signature: BLSSignature +// data: AttestationData - target data - 128 +// signature: BLSSignature - 96 // // class AttestationData(Container): 128 bytes fixed size // slot: Slot - data 8 @@ -25,22 +26,23 @@ export type AttDataBase64 = string; // aggregate: Attestation - offset 4 // selectionProof: BLSSignature - data 96 -const ATTESTATION_SLOT_OFFSET = 4; -const ATTESTATION_BEACON_BLOCK_ROOT_OFFSET = ATTESTATION_SLOT_OFFSET + 8 + 8; +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 ATTESTATION_DATA_SIZE = 128; +const SIGNATURE_SIZE = 96; /** * Extract slot from attestation serialized bytes. * Return null if data is not long enough to extract slot. */ export function getSlotFromAttestationSerialized(data: Uint8Array): Slot | null { - if (data.length < ATTESTATION_SLOT_OFFSET + SLOT_SIZE) { + if (data.length < VARIABLE_FIELD_OFFSET + SLOT_SIZE) { return null; } - return getSlotFromOffset(data, ATTESTATION_SLOT_OFFSET); + return getSlotFromOffset(data, VARIABLE_FIELD_OFFSET); } /** @@ -60,19 +62,51 @@ export function getBlockRootFromAttestationSerialized(data: Uint8Array): BlockRo * Return null if data is not long enough to extract attestation data. */ export function getAttDataBase64FromAttestationSerialized(data: Uint8Array): AttDataBase64 | null { - if (data.length < ATTESTATION_SLOT_OFFSET + ATTESTATION_DATA_SIZE) { + if (data.length < VARIABLE_FIELD_OFFSET + ATTESTATION_DATA_SIZE) { return null; } // base64 is a bit efficient than hex - return Buffer.from(data.slice(ATTESTATION_SLOT_OFFSET, ATTESTATION_SLOT_OFFSET + ATTESTATION_DATA_SIZE)).toString( + return Buffer.from(data.slice(VARIABLE_FIELD_OFFSET, VARIABLE_FIELD_OFFSET + ATTESTATION_DATA_SIZE)).toString( "base64" ); } +/** + * Extract aggregation bits from attestation serialized bytes. + * Return null if data is not long enough to extract aggregation bits. + */ +export function getAggregationBitsFromAttestationSerialized(data: Uint8Array): BitArray | null { + if (data.length < VARIABLE_FIELD_OFFSET + ATTESTATION_DATA_SIZE + SIGNATURE_SIZE) { + return null; + } + + const {uint8Array, bitLen} = deserializeUint8ArrayBitListFromBytes( + data, + VARIABLE_FIELD_OFFSET + ATTESTATION_DATA_SIZE + SIGNATURE_SIZE, + data.length + ); + return new BitArray(uint8Array, bitLen); +} + +/** + * Extract signature from attestation serialized bytes. + * Return null if data is not long enough to extract signature. + */ +export function getSignatureFromAttestationSerialized(data: Uint8Array): BLSSignature | null { + if (data.length < VARIABLE_FIELD_OFFSET + ATTESTATION_DATA_SIZE + SIGNATURE_SIZE) { + return null; + } + + return data.subarray( + VARIABLE_FIELD_OFFSET + ATTESTATION_DATA_SIZE, + VARIABLE_FIELD_OFFSET + ATTESTATION_DATA_SIZE + SIGNATURE_SIZE + ); +} + const AGGREGATE_AND_PROOF_OFFSET = 4 + 96; const AGGREGATE_OFFSET = AGGREGATE_AND_PROOF_OFFSET + 8 + 4 + 96; -const SIGNED_AGGREGATE_AND_PROOF_SLOT_OFFSET = AGGREGATE_OFFSET + ATTESTATION_SLOT_OFFSET; +const SIGNED_AGGREGATE_AND_PROOF_SLOT_OFFSET = AGGREGATE_OFFSET + VARIABLE_FIELD_OFFSET; const SIGNED_AGGREGATE_AND_PROOF_BLOCK_ROOT_OFFSET = SIGNED_AGGREGATE_AND_PROOF_SLOT_OFFSET + 8 + 8; /** @@ -125,3 +159,39 @@ function getSlotFromOffset(data: Uint8Array, offset: number): Slot { // Read only the first 4 bytes of Slot, max value is 4,294,967,295 will be reached 1634 years after genesis return dv.getUint32(offset, true); } + +type BitArrayDeserialized = {uint8Array: Uint8Array; bitLen: number}; + +/** + * This is copied from ssz bitList.ts + * TODO: export this util from there + */ +function deserializeUint8ArrayBitListFromBytes(data: Uint8Array, start: number, end: number): BitArrayDeserialized { + if (end > data.length) { + throw Error(`BitList attempting to read byte ${end} of data length ${data.length}`); + } + + const lastByte = data[end - 1]; + const size = end - start; + + if (lastByte === 0) { + throw new Error("Invalid deserialized bitlist, padding bit required"); + } + + if (lastByte === 1) { + // Buffer.prototype.slice does not copy memory, Enforce Uint8Array usage https://github.com/nodejs/node/issues/28087 + const uint8Array = Uint8Array.prototype.slice.call(data, start, end - 1); + const bitLen = (size - 1) * 8; + return {uint8Array, bitLen}; + } + + // the last byte is > 1, so a padding bit will exist in the last byte and need to be removed + // Buffer.prototype.slice does not copy memory, Enforce Uint8Array usage https://github.com/nodejs/node/issues/28087 + const uint8Array = Uint8Array.prototype.slice.call(data, start, end); + // mask lastChunkByte + const lastByteBitLength = lastByte.toString(2).length - 1; + const bitLen = (size - 1) * 8 + lastByteBitLength; + const mask = 0xff >> (8 - lastByteBitLength); + uint8Array[size - 1] &= mask; + return {uint8Array, bitLen}; +} diff --git a/packages/beacon-node/test/e2e/network/gossipsub.test.ts b/packages/beacon-node/test/e2e/network/gossipsub.test.ts index ee1bb71b2a75..0957dfc38160 100644 --- a/packages/beacon-node/test/e2e/network/gossipsub.test.ts +++ b/packages/beacon-node/test/e2e/network/gossipsub.test.ts @@ -1,10 +1,10 @@ import sinon from "sinon"; import {expect} from "chai"; import {createBeaconConfig, createChainForkConfig, defaultChainConfig} from "@lodestar/config"; -import {capella, phase0, ssz, allForks} from "@lodestar/types"; import {sleep} from "@lodestar/utils"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {ssz} from "@lodestar/types"; import {getReqRespHandlers, Network, NetworkInitModules} from "../../../src/network/index.js"; import {defaultNetworkOptions, NetworkOptions} from "../../../src/network/options.js"; import {GossipType, GossipHandlers} from "../../../src/network/gossip/index.js"; @@ -117,12 +117,12 @@ describe("gossipsub", function () { } it("Publish and receive a voluntaryExit", async function () { - let onVoluntaryExit: (ve: phase0.SignedVoluntaryExit) => void; - const onVoluntaryExitPromise = new Promise((resolve) => (onVoluntaryExit = resolve)); + let onVoluntaryExit: (ve: Uint8Array) => void; + const onVoluntaryExitPromise = new Promise((resolve) => (onVoluntaryExit = resolve)); const {netA, netB, controller} = await mockModules({ - [GossipType.voluntary_exit]: async (voluntaryExit) => { - onVoluntaryExit(voluntaryExit); + [GossipType.voluntary_exit]: async ({serializedData}) => { + onVoluntaryExit(serializedData); }, }); @@ -146,15 +146,15 @@ describe("gossipsub", function () { await netA.gossip.publishVoluntaryExit(voluntaryExit); const receivedVoluntaryExit = await onVoluntaryExitPromise; - expect(receivedVoluntaryExit).to.deep.equal(voluntaryExit); + expect(receivedVoluntaryExit).to.deep.equal(ssz.phase0.SignedVoluntaryExit.serialize(voluntaryExit)); }); it("Publish and receive 1000 voluntaryExits", async function () { - const receivedVoluntaryExits: phase0.SignedVoluntaryExit[] = []; + const receivedVoluntaryExits: Uint8Array[] = []; const {netA, netB, controller} = await mockModules({ - [GossipType.voluntary_exit]: async (voluntaryExit) => { - receivedVoluntaryExits.push(voluntaryExit); + [GossipType.voluntary_exit]: async ({serializedData}) => { + receivedVoluntaryExits.push(serializedData); }, }); @@ -194,14 +194,12 @@ describe("gossipsub", function () { }); it("Publish and receive a blsToExecutionChange", async function () { - let onBlsToExecutionChange: (blsToExec: capella.SignedBLSToExecutionChange) => void; - const onBlsToExecutionChangePromise = new Promise( - (resolve) => (onBlsToExecutionChange = resolve) - ); + let onBlsToExecutionChange: (blsToExec: Uint8Array) => void; + const onBlsToExecutionChangePromise = new Promise((resolve) => (onBlsToExecutionChange = resolve)); const {netA, netB, controller} = await mockModules({ - [GossipType.bls_to_execution_change]: async (blsToExec) => { - onBlsToExecutionChange(blsToExec); + [GossipType.bls_to_execution_change]: async ({serializedData}) => { + onBlsToExecutionChange(serializedData); }, }); @@ -225,18 +223,18 @@ describe("gossipsub", function () { await netA.gossip.publishBlsToExecutionChange(blsToExec); const receivedblsToExec = await onBlsToExecutionChangePromise; - expect(receivedblsToExec).to.deep.equal(blsToExec); + expect(receivedblsToExec).to.deep.equal(ssz.capella.SignedBLSToExecutionChange.serialize(blsToExec)); }); it("Publish and receive a LightClientOptimisticUpdate", async function () { - let onLightClientOptimisticUpdate: (ou: allForks.LightClientOptimisticUpdate) => void; - const onLightClientOptimisticUpdatePromise = new Promise( + let onLightClientOptimisticUpdate: (ou: Uint8Array) => void; + const onLightClientOptimisticUpdatePromise = new Promise( (resolve) => (onLightClientOptimisticUpdate = resolve) ); const {netA, netB, controller} = await mockModules({ - [GossipType.light_client_optimistic_update]: async (lightClientOptimisticUpdate) => { - onLightClientOptimisticUpdate(lightClientOptimisticUpdate); + [GossipType.light_client_optimistic_update]: async ({serializedData}) => { + onLightClientOptimisticUpdate(serializedData); }, }); @@ -261,18 +259,20 @@ describe("gossipsub", function () { await netA.gossip.publishLightClientOptimisticUpdate(lightClientOptimisticUpdate); const optimisticUpdate = await onLightClientOptimisticUpdatePromise; - expect(optimisticUpdate).to.deep.equal(lightClientOptimisticUpdate); + expect(optimisticUpdate).to.deep.equal( + ssz.capella.LightClientOptimisticUpdate.serialize(lightClientOptimisticUpdate) + ); }); it("Publish and receive a LightClientFinalityUpdate", async function () { - let onLightClientFinalityUpdate: (fu: allForks.LightClientFinalityUpdate) => void; - const onLightClientFinalityUpdatePromise = new Promise( + let onLightClientFinalityUpdate: (fu: Uint8Array) => void; + const onLightClientFinalityUpdatePromise = new Promise( (resolve) => (onLightClientFinalityUpdate = resolve) ); const {netA, netB, controller} = await mockModules({ - [GossipType.light_client_finality_update]: async (lightClientFinalityUpdate) => { - onLightClientFinalityUpdate(lightClientFinalityUpdate); + [GossipType.light_client_finality_update]: async ({serializedData}) => { + onLightClientFinalityUpdate(serializedData); }, }); @@ -297,6 +297,6 @@ describe("gossipsub", function () { await netA.gossip.publishLightClientFinalityUpdate(lightClientFinalityUpdate); const optimisticUpdate = await onLightClientFinalityUpdatePromise; - expect(optimisticUpdate).to.deep.equal(lightClientFinalityUpdate); + expect(optimisticUpdate).to.deep.equal(ssz.capella.LightClientFinalityUpdate.serialize(lightClientFinalityUpdate)); }); }); diff --git a/packages/beacon-node/test/perf/chain/validation/attestation.test.ts b/packages/beacon-node/test/perf/chain/validation/attestation.test.ts index 351217e5549e..146b9705a5ce 100644 --- a/packages/beacon-node/test/perf/chain/validation/attestation.test.ts +++ b/packages/beacon-node/test/perf/chain/validation/attestation.test.ts @@ -19,7 +19,7 @@ describe("validate gossip attestation", () => { id: `validate gossip attestation - ${id}`, beforeEach: () => chain.seenAttesters["validatorIndexesByEpoch"].clear(), fn: async () => { - await validateGossipAttestation(chain, att, subnet); + await validateGossipAttestation(chain, {attestation: att, serializedData: null}, subnet); }, }); } diff --git a/packages/beacon-node/test/unit/chain/validation/attestation.test.ts b/packages/beacon-node/test/unit/chain/validation/attestation.test.ts index b457a589a7c0..ba0b96e053a5 100644 --- a/packages/beacon-node/test/unit/chain/validation/attestation.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/attestation.test.ts @@ -1,10 +1,10 @@ import {SLOTS_PER_EPOCH} from "@lodestar/params"; -import {phase0} from "@lodestar/types"; import {BitArray} from "@chainsafe/ssz"; import {processSlots} from "@lodestar/state-transition"; +import {ssz} from "@lodestar/types"; import {IBeaconChain} from "../../../../src/chain/index.js"; -import {AttestationErrorCode} from "../../../../src/chain/errors/index.js"; -import {validateGossipAttestation} from "../../../../src/chain/validation/index.js"; +import {AttestationErrorCode, INVALID_SERIALIZED_BYTES_ERROR_CODE} from "../../../../src/chain/errors/index.js"; +import {AttestationOrBytes, validateGossipAttestation} from "../../../../src/chain/validation/index.js"; import {expectRejectedWithLodestarError} from "../../../utils/errors.js"; import {generateTestCachedBeaconStateOnlyValidators} from "../../../../../state-transition/test/perf/util.js"; import {memoOnce} from "../../../utils/cache.js"; @@ -38,7 +38,17 @@ describe("chain / validation / attestation", () => { it("Valid", async () => { const {chain, attestation, subnet} = getValidData(); - await validateGossipAttestation(chain, attestation, subnet); + await validateGossipAttestation(chain, {attestation, serializedData: null}, subnet); + }); + + it("INVALID_SERIALIZED_BYTES_ERROR_CODE", async () => { + const {chain, subnet} = getValidData(); + await expectError( + chain, + {attestation: null, serializedData: Buffer.alloc(0), attSlot: 0}, + subnet, + INVALID_SERIALIZED_BYTES_ERROR_CODE + ); }); it("BAD_TARGET_EPOCH", async () => { @@ -46,22 +56,43 @@ describe("chain / validation / attestation", () => { // Change target epoch to it doesn't match data.slot attestation.data.target.epoch += 1; - - await expectError(chain, attestation, subnet, AttestationErrorCode.BAD_TARGET_EPOCH); + const serializedData = ssz.phase0.Attestation.serialize(attestation); + + await expectError(chain, {attestation, serializedData: null}, subnet, AttestationErrorCode.BAD_TARGET_EPOCH); + await expectError( + chain, + {attestation: null, serializedData, attSlot: attestation.data.slot}, + subnet, + AttestationErrorCode.BAD_TARGET_EPOCH + ); }); it("PAST_SLOT", async () => { // Set attestation at a very old slot const {chain, attestation, subnet} = getValidData({attSlot: stateSlot - SLOTS_PER_EPOCH - 3}); - - await expectError(chain, attestation, subnet, AttestationErrorCode.PAST_SLOT); + const serializedData = ssz.phase0.Attestation.serialize(attestation); + + await expectError(chain, {attestation, serializedData: null}, subnet, AttestationErrorCode.PAST_SLOT); + await expectError( + chain, + {attestation: null, serializedData, attSlot: attestation.data.slot}, + subnet, + AttestationErrorCode.PAST_SLOT + ); }); it("FUTURE_SLOT", async () => { // Set attestation to a future slot const {chain, attestation, subnet} = getValidData({attSlot: stateSlot + 2}); - - await expectError(chain, attestation, subnet, AttestationErrorCode.FUTURE_SLOT); + const serializedData = ssz.phase0.Attestation.serialize(attestation); + + await expectError(chain, {attestation, serializedData: null}, subnet, AttestationErrorCode.FUTURE_SLOT); + await expectError( + chain, + {attestation: null, serializedData, attSlot: attestation.data.slot}, + subnet, + AttestationErrorCode.FUTURE_SLOT + ); }); it("NOT_EXACTLY_ONE_AGGREGATION_BIT_SET - 0 bits", async () => { @@ -69,8 +100,20 @@ describe("chain / validation / attestation", () => { const bitIndex = 1; const {chain, attestation, subnet} = getValidData({bitIndex}); attestation.aggregationBits.set(bitIndex, false); + const serializedData = ssz.phase0.Attestation.serialize(attestation); - await expectError(chain, attestation, subnet, AttestationErrorCode.NOT_EXACTLY_ONE_AGGREGATION_BIT_SET); + await expectError( + chain, + {attestation, serializedData: null}, + subnet, + AttestationErrorCode.NOT_EXACTLY_ONE_AGGREGATION_BIT_SET + ); + await expectError( + chain, + {attestation: null, serializedData, attSlot: attestation.data.slot}, + subnet, + AttestationErrorCode.NOT_EXACTLY_ONE_AGGREGATION_BIT_SET + ); }); it("NOT_EXACTLY_ONE_AGGREGATION_BIT_SET - 2 bits", async () => { @@ -78,24 +121,49 @@ describe("chain / validation / attestation", () => { const bitIndex = 1; const {chain, attestation, subnet} = getValidData({bitIndex}); attestation.aggregationBits.set(bitIndex + 1, true); + const serializedData = ssz.phase0.Attestation.serialize(attestation); - await expectError(chain, attestation, subnet, AttestationErrorCode.NOT_EXACTLY_ONE_AGGREGATION_BIT_SET); + await expectError( + chain, + {attestation: null, serializedData, attSlot: attestation.data.slot}, + subnet, + AttestationErrorCode.NOT_EXACTLY_ONE_AGGREGATION_BIT_SET + ); }); it("UNKNOWN_BEACON_BLOCK_ROOT", async () => { const {chain, attestation, subnet} = getValidData(); // Set beaconBlockRoot to a root not known by the fork choice attestation.data.beaconBlockRoot = UNKNOWN_ROOT; + const serializedData = ssz.phase0.Attestation.serialize(attestation); - await expectError(chain, attestation, subnet, AttestationErrorCode.UNKNOWN_OR_PREFINALIZED_BEACON_BLOCK_ROOT); + await expectError( + chain, + {attestation, serializedData: null}, + subnet, + AttestationErrorCode.UNKNOWN_OR_PREFINALIZED_BEACON_BLOCK_ROOT + ); + await expectError( + chain, + {attestation: null, serializedData, attSlot: attestation.data.slot}, + subnet, + AttestationErrorCode.UNKNOWN_OR_PREFINALIZED_BEACON_BLOCK_ROOT + ); }); it("INVALID_TARGET_ROOT", async () => { const {chain, attestation, subnet} = getValidData(); // Set target.root to an unknown root attestation.data.target.root = UNKNOWN_ROOT; - - await expectError(chain, attestation, subnet, AttestationErrorCode.INVALID_TARGET_ROOT); + const serializedData = ssz.phase0.Attestation.serialize(attestation); + + await expectError(chain, {attestation, serializedData: null}, subnet, AttestationErrorCode.INVALID_TARGET_ROOT); + await expectError( + chain, + {attestation: null, serializedData, attSlot: attestation.data.slot}, + subnet, + AttestationErrorCode.INVALID_TARGET_ROOT + ); }); it("NO_COMMITTEE_FOR_SLOT_AND_INDEX", async () => { @@ -107,8 +175,20 @@ describe("chain / validation / attestation", () => { (chain as {regen: IStateRegenerator}).regen = { getState: async () => committeeState, } as Partial as IStateRegenerator; + const serializedData = ssz.phase0.Attestation.serialize(attestation); - await expectError(chain, attestation, subnet, AttestationErrorCode.NO_COMMITTEE_FOR_SLOT_AND_INDEX); + await expectError( + chain, + {attestation, serializedData: null}, + subnet, + AttestationErrorCode.NO_COMMITTEE_FOR_SLOT_AND_INDEX + ); + await expectError( + chain, + {attestation: null, serializedData, attSlot: attestation.data.slot}, + subnet, + AttestationErrorCode.NO_COMMITTEE_FOR_SLOT_AND_INDEX + ); }); it("WRONG_NUMBER_OF_AGGREGATION_BITS", async () => { @@ -118,24 +198,60 @@ describe("chain / validation / attestation", () => { attestation.aggregationBits.uint8Array, attestation.aggregationBits.bitLen + 1 ); + const serializedData = ssz.phase0.Attestation.serialize(attestation); - await expectError(chain, attestation, subnet, AttestationErrorCode.WRONG_NUMBER_OF_AGGREGATION_BITS); + await expectError( + chain, + {attestation, serializedData: null}, + subnet, + AttestationErrorCode.WRONG_NUMBER_OF_AGGREGATION_BITS + ); + await expectError( + chain, + {attestation: null, serializedData, attSlot: attestation.data.slot}, + subnet, + AttestationErrorCode.WRONG_NUMBER_OF_AGGREGATION_BITS + ); }); it("INVALID_SUBNET_ID", async () => { const {chain, attestation, subnet} = getValidData(); // Pass a different subnet value than the correct one const invalidSubnet = subnet === 0 ? 1 : 0; + const serializedData = ssz.phase0.Attestation.serialize(attestation); - await expectError(chain, attestation, invalidSubnet, AttestationErrorCode.INVALID_SUBNET_ID); + await expectError( + chain, + {attestation, serializedData: null}, + invalidSubnet, + AttestationErrorCode.INVALID_SUBNET_ID + ); + await expectError( + chain, + {attestation: null, serializedData, attSlot: attestation.data.slot}, + invalidSubnet, + AttestationErrorCode.INVALID_SUBNET_ID + ); }); it("ATTESTATION_ALREADY_KNOWN", async () => { const {chain, attestation, subnet, validatorIndex} = getValidData(); // Register attester as already seen chain.seenAttesters.add(attestation.data.target.epoch, validatorIndex); + const serializedData = ssz.phase0.Attestation.serialize(attestation); - await expectError(chain, attestation, subnet, AttestationErrorCode.ATTESTATION_ALREADY_KNOWN); + await expectError( + chain, + {attestation, serializedData: null}, + subnet, + AttestationErrorCode.ATTESTATION_ALREADY_KNOWN + ); + await expectError( + chain, + {attestation: null, serializedData, attSlot: attestation.data.slot}, + subnet, + AttestationErrorCode.ATTESTATION_ALREADY_KNOWN + ); }); it("INVALID_SIGNATURE", async () => { @@ -144,17 +260,24 @@ describe("chain / validation / attestation", () => { // Change the bit index so the signature is validated against a different pubkey attestation.aggregationBits.set(bitIndex, false); attestation.aggregationBits.set(bitIndex + 1, true); - - await expectError(chain, attestation, subnet, AttestationErrorCode.INVALID_SIGNATURE); + const serializedData = ssz.phase0.Attestation.serialize(attestation); + + await expectError(chain, {attestation, serializedData: null}, subnet, AttestationErrorCode.INVALID_SIGNATURE); + await expectError( + chain, + {attestation: null, serializedData, attSlot: attestation.data.slot}, + subnet, + AttestationErrorCode.INVALID_SIGNATURE + ); }); /** Alias to reduce code duplication */ async function expectError( chain: IBeaconChain, - attestation: phase0.Attestation, + attestationOrBytes: AttestationOrBytes, subnet: number, - errorCode: AttestationErrorCode + errorCode: string ): Promise { - await expectRejectedWithLodestarError(validateGossipAttestation(chain, attestation, subnet), errorCode); + await expectRejectedWithLodestarError(validateGossipAttestation(chain, attestationOrBytes, subnet), errorCode); } }); diff --git a/packages/beacon-node/test/unit/util/sszBytes.test.ts b/packages/beacon-node/test/unit/util/sszBytes.test.ts index 06aa32979857..f2efee679f92 100644 --- a/packages/beacon-node/test/unit/util/sszBytes.test.ts +++ b/packages/beacon-node/test/unit/util/sszBytes.test.ts @@ -4,10 +4,12 @@ import {fromHex, toHex} from "@lodestar/utils"; import { getAttDataBase64FromAttestationSerialized, getAttDataBase64FromSignedAggregateAndProofSerialized, + getAggregationBitsFromAttestationSerialized as getAggregationBitsFromAttestationSerialized, getBlockRootFromAttestationSerialized, getBlockRootFromSignedAggregateAndProofSerialized, getSlotFromAttestationSerialized, getSlotFromSignedAggregateAndProofSerialized, + getSignatureFromAttestationSerialized, } from "../../../src/util/sszBytes.js"; describe("attestation SSZ serialized picking", () => { @@ -27,6 +29,10 @@ describe("attestation SSZ serialized picking", () => { expect(getSlotFromAttestationSerialized(bytes)).equals(attestation.data.slot); expect(getBlockRootFromAttestationSerialized(bytes)).equals(toHex(attestation.data.beaconBlockRoot)); + expect(getAggregationBitsFromAttestationSerialized(bytes)?.toBoolArray()).to.be.deep.equals( + attestation.aggregationBits.toBoolArray() + ); + expect(getSignatureFromAttestationSerialized(bytes)).to.be.deep.equals(attestation.signature); const attDataBase64 = ssz.phase0.AttestationData.serialize(attestation.data); expect(getAttDataBase64FromAttestationSerialized(bytes)).to.be.equal( @@ -55,6 +61,20 @@ describe("attestation SSZ serialized picking", () => { expect(getAttDataBase64FromAttestationSerialized(Buffer.alloc(size))).to.be.null; } }); + + it("getAggregateionBitsFromAttestationSerialized - invalid data", () => { + const invalidAggregationBitsDataSizes = [0, 4, 100, 128, 227]; + for (const size of invalidAggregationBitsDataSizes) { + expect(getAggregationBitsFromAttestationSerialized(Buffer.alloc(size))).to.be.null; + } + }); + + it("getSignatureFromAttestationSerialized - invalid data", () => { + const invalidSignatureDataSizes = [0, 4, 100, 128, 227]; + for (const size of invalidSignatureDataSizes) { + expect(getSignatureFromAttestationSerialized(Buffer.alloc(size))).to.be.null; + } + }); }); describe("aggregateAndProof SSZ serialized peaking", () => { diff --git a/packages/beacon-node/test/utils/mocks/chain/chain.ts b/packages/beacon-node/test/utils/mocks/chain/chain.ts index 45137f5c251e..7fb7629770a2 100644 --- a/packages/beacon-node/test/utils/mocks/chain/chain.ts +++ b/packages/beacon-node/test/utils/mocks/chain/chain.ts @@ -48,6 +48,7 @@ import {IChainOptions} from "../../../../src/chain/options.js"; import {BlockAttributes} from "../../../../src/chain/produceBlock/produceBlockBody.js"; import {ReqRespBlockResponse} from "../../../../src/network/index.js"; import {SeenAttestationDatas} from "../../../../src/chain/seenCache/seenAttestationData.js"; +import {IExecutionBuilder} from "../../../../src/execution/index.js"; /* eslint-disable @typescript-eslint/no-empty-function */ @@ -164,6 +165,7 @@ export class MockBeaconChain implements IBeaconChain { this.pubkey2index = new PubkeyIndexMap(); this.index2pubkey = []; } + executionBuilder?: IExecutionBuilder | undefined; validatorSeenAtEpoch(): boolean { return false; @@ -232,6 +234,10 @@ export class MockBeaconChain implements IBeaconChain { return; } + persistInvalidSszBytes(): void { + return; + } + persistInvalidSszValue(): void { return; } diff --git a/packages/beacon-node/test/utils/validationData/attestation.ts b/packages/beacon-node/test/utils/validationData/attestation.ts index 20040ede6b1e..dc4b0590227c 100644 --- a/packages/beacon-node/test/utils/validationData/attestation.ts +++ b/packages/beacon-node/test/utils/validationData/attestation.ts @@ -17,6 +17,7 @@ import {BlsSingleThreadVerifier} from "../../../src/chain/bls/index.js"; import {signCached} from "../cache.js"; import {ClockStatic} from "../clock.js"; import {SeenAggregatedAttestations} from "../../../src/chain/seenCache/seenAggregateAndProof.js"; +import {SeenAttestationDatas} from "../../../src/chain/seenCache/seenAttestationData.js"; export type AttestationValidDataOpts = { currentSlot?: Slot; @@ -120,6 +121,7 @@ export function getAttestationValidData(opts: AttestationValidDataOpts): { regen, seenAttesters: new SeenAttesters(), seenAggregatedAttestations: new SeenAggregatedAttestations(null), + seenAttestationDatas: new SeenAttestationDatas(null, 0, 0), bls: new BlsSingleThreadVerifier({metrics: null}), waitForBlock: () => Promise.resolve(false), index2pubkey: state.epochCtx.index2pubkey,