diff --git a/packages/api/src/beacon/routes/events.ts b/packages/api/src/beacon/routes/events.ts index e22097299c58..d15f2468e5e0 100644 --- a/packages/api/src/beacon/routes/events.ts +++ b/packages/api/src/beacon/routes/events.ts @@ -5,18 +5,6 @@ import {RouteDef, TypeJson} from "../../utils/index.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes -export type LightclientOptimisticHeaderUpdate = { - syncAggregate: altair.SyncAggregate; - attestedHeader: phase0.BeaconBlockHeader; -}; - -export type LightclientFinalizedUpdate = { - attestedHeader: phase0.BeaconBlockHeader; - finalizedHeader: phase0.BeaconBlockHeader; - finalityBranch: Uint8Array[]; - syncAggregate: altair.SyncAggregate; -}; - export enum EventType { /** * The node has finished processing, resulting in a new head. previous_duty_dependent_root is @@ -38,9 +26,11 @@ export enum EventType { /** The node has received a valid sync committee SignedContributionAndProof (from P2P or API) */ contributionAndProof = "contribution_and_proof", /** New or better optimistic header update available */ - lightclientOptimisticUpdate = "light_client_optimistic_update", - /** New or better finalized update available */ - lightclientFinalizedUpdate = "light_client_finalized_update", + lightClientOptimisticUpdate = "light_client_optimistic_update", + /** New or better finality update available */ + lightClientFinalityUpdate = "light_client_finality_update", + /** New or better light client update available */ + lightClientUpdate = "light_client_update", } export type EventData = { @@ -77,8 +67,9 @@ export type EventData = { executionOptimistic: boolean; }; [EventType.contributionAndProof]: altair.SignedContributionAndProof; - [EventType.lightclientOptimisticUpdate]: LightclientOptimisticHeaderUpdate; - [EventType.lightclientFinalizedUpdate]: LightclientFinalizedUpdate; + [EventType.lightClientOptimisticUpdate]: altair.LightClientOptimisticUpdate; + [EventType.lightClientFinalityUpdate]: altair.LightClientFinalityUpdate; + [EventType.lightClientUpdate]: altair.LightClientUpdate; }; export type BeaconEvent = {[K in EventType]: {type: K; message: EventData[K]}}[EventType]; @@ -163,22 +154,25 @@ export function getTypeByEvent(): {[K in EventType]: Type} { [EventType.contributionAndProof]: ssz.altair.SignedContributionAndProof, - [EventType.lightclientOptimisticUpdate]: new ContainerType( + [EventType.lightClientOptimisticUpdate]: new ContainerType( { syncAggregate: ssz.altair.SyncAggregate, attestedHeader: ssz.phase0.BeaconBlockHeader, + signatureSlot: ssz.Slot, }, {jsonCase: "eth2"} ), - [EventType.lightclientFinalizedUpdate]: new ContainerType( + [EventType.lightClientFinalityUpdate]: new ContainerType( { attestedHeader: ssz.phase0.BeaconBlockHeader, finalizedHeader: ssz.phase0.BeaconBlockHeader, finalityBranch: new VectorCompositeType(ssz.Bytes32, FINALIZED_ROOT_DEPTH), syncAggregate: ssz.altair.SyncAggregate, + signatureSlot: ssz.Slot, }, {jsonCase: "eth2"} ), + [EventType.lightClientUpdate]: ssz.altair.LightClientUpdate, }; } diff --git a/packages/api/src/beacon/routes/lightclient.ts b/packages/api/src/beacon/routes/lightclient.ts index e36e89d3639c..c0e991a36b03 100644 --- a/packages/api/src/beacon/routes/lightclient.ts +++ b/packages/api/src/beacon/routes/lightclient.ts @@ -1,6 +1,5 @@ -import {ContainerType, JsonPath, VectorCompositeType} from "@chainsafe/ssz"; +import {JsonPath} from "@chainsafe/ssz"; import {Proof} from "@chainsafe/persistent-merkle-tree"; -import {FINALIZED_ROOT_DEPTH} from "@lodestar/params"; import {altair, phase0, ssz, SyncPeriod} from "@lodestar/types"; import { ArrayOf, @@ -14,14 +13,10 @@ import { ReqEmpty, } from "../../utils/index.js"; import {queryParseProofPathsArr, querySerializeProofPathsArr} from "../../utils/serdes.js"; -import {LightclientOptimisticHeaderUpdate, LightclientFinalizedUpdate} from "./events.js"; - -// Re-export for convenience when importing routes.lightclient.LightclientOptimisticHeaderUpdate -export {LightclientOptimisticHeaderUpdate, LightclientFinalizedUpdate}; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes -export type LightclientSnapshotWithProof = { +export type LightClientBootstrap = { header: phase0.BeaconBlockHeader; currentSyncCommittee: altair.SyncCommittee; /** Single branch proof from state root to currentSyncCommittee */ @@ -46,14 +41,14 @@ export type Api = { * Returns the latest optimistic head update available. Clients should use the SSE type `light_client_optimistic_update` * unless to get the very first head update after syncing, or if SSE are not supported by the server. */ - getOptimisticUpdate(): Promise<{data: LightclientOptimisticHeaderUpdate}>; - getFinalityUpdate(): Promise<{data: LightclientFinalizedUpdate}>; + getOptimisticUpdate(): Promise<{data: altair.LightClientOptimisticUpdate}>; + getFinalityUpdate(): Promise<{data: altair.LightClientFinalityUpdate}>; /** * Fetch a bootstrapping state with a proof to a trusted block root. * The trusted block root should be fetched with similar means to a weak subjectivity checkpoint. * Only block roots for checkpoints are guaranteed to be available. */ - getBootstrap(blockRoot: string): Promise<{data: LightclientSnapshotWithProof}>; + getBootstrap(blockRoot: string): Promise<{data: LightClientBootstrap}>; }; /** @@ -62,8 +57,8 @@ export type Api = { export const routesData: RoutesData = { getStateProof: {url: "/eth/v1/beacon/light_client/proof/{state_id}", method: "GET"}, getUpdates: {url: "/eth/v1/beacon/light_client/updates", method: "GET"}, - getOptimisticUpdate: {url: "/eth/v1/beacon/light_client/optimistic_update/", method: "GET"}, - getFinalityUpdate: {url: "/eth/v1/beacon/light_client/finality_update/", method: "GET"}, + getOptimisticUpdate: {url: "/eth/v1/beacon/light_client/optimistic_update", method: "GET"}, + getFinalityUpdate: {url: "/eth/v1/beacon/light_client/finality_update", method: "GET"}, getBootstrap: {url: "/eth/v1/beacon/light_client/bootstrap/{block_root}", method: "GET"}, }; @@ -102,39 +97,12 @@ export function getReqSerializers(): ReqSerializers { } export function getReturnTypes(): ReturnTypes { - const lightclientSnapshotWithProofType = new ContainerType( - { - header: ssz.phase0.BeaconBlockHeader, - currentSyncCommittee: ssz.altair.SyncCommittee, - currentSyncCommitteeBranch: new VectorCompositeType(ssz.Root, 5), - }, - {jsonCase: "eth2"} - ); - - const lightclientHeaderUpdate = new ContainerType( - { - syncAggregate: ssz.altair.SyncAggregate, - attestedHeader: ssz.phase0.BeaconBlockHeader, - }, - {jsonCase: "eth2"} - ); - - const lightclientFinalizedUpdate = new ContainerType( - { - attestedHeader: ssz.phase0.BeaconBlockHeader, - finalizedHeader: ssz.phase0.BeaconBlockHeader, - finalityBranch: new VectorCompositeType(ssz.Bytes32, FINALIZED_ROOT_DEPTH), - syncAggregate: ssz.altair.SyncAggregate, - }, - {jsonCase: "eth2"} - ); - return { // Just sent the proof JSON as-is getStateProof: sameType(), getUpdates: ContainerData(ArrayOf(ssz.altair.LightClientUpdate)), - getOptimisticUpdate: ContainerData(lightclientHeaderUpdate), - getFinalityUpdate: ContainerData(lightclientFinalizedUpdate), - getBootstrap: ContainerData(lightclientSnapshotWithProofType), + getOptimisticUpdate: ContainerData(ssz.altair.LightClientOptimisticUpdate), + getFinalityUpdate: ContainerData(ssz.altair.LightClientFinalityUpdate), + getBootstrap: ContainerData(ssz.altair.LightClientBootstrap), }; } diff --git a/packages/api/test/unit/beacon/testData/events.ts b/packages/api/test/unit/beacon/testData/events.ts index 08406804f039..3ddf139dfbba 100644 --- a/packages/api/test/unit/beacon/testData/events.ts +++ b/packages/api/test/unit/beacon/testData/events.ts @@ -81,14 +81,17 @@ export const eventTestData: EventData = { signature: "0xac118511474a94f857300b315c50585c32a713e4452e26a6bb98cdb619936370f126ed3b6bb64469259ee92e69791d9e12d324ce6fd90081680ce72f39d85d50b0ff977260a8667465e613362c6d6e6e745e1f9323ec1d6f16041c4e358839ac", }), - [EventType.lightclientOptimisticUpdate]: { + [EventType.lightClientOptimisticUpdate]: { syncAggregate: ssz.altair.SyncAggregate.defaultValue(), attestedHeader: ssz.phase0.BeaconBlockHeader.defaultValue(), + signatureSlot: ssz.Slot.defaultValue(), }, - [EventType.lightclientFinalizedUpdate]: { + [EventType.lightClientFinalityUpdate]: { attestedHeader: ssz.phase0.BeaconBlockHeader.defaultValue(), finalizedHeader: ssz.phase0.BeaconBlockHeader.defaultValue(), finalityBranch: [root], syncAggregate: ssz.altair.SyncAggregate.defaultValue(), + signatureSlot: ssz.Slot.defaultValue(), }, + [EventType.lightClientUpdate]: ssz.altair.LightClientUpdate.defaultValue(), }; diff --git a/packages/api/test/unit/beacon/testData/lightclient.ts b/packages/api/test/unit/beacon/testData/lightclient.ts index f922a0475438..20dc9e6cae94 100644 --- a/packages/api/test/unit/beacon/testData/lightclient.ts +++ b/packages/api/test/unit/beacon/testData/lightclient.ts @@ -9,6 +9,7 @@ const root = Uint8Array.from(Buffer.alloc(32, 1)); const lightClientUpdate = ssz.altair.LightClientUpdate.defaultValue(); const syncAggregate = ssz.altair.SyncAggregate.defaultValue(); const header = ssz.phase0.BeaconBlockHeader.defaultValue(); +const signatureSlot = ssz.Slot.defaultValue(); export const testData: GenericServerTestCases = { getStateProof: { @@ -41,7 +42,7 @@ export const testData: GenericServerTestCases = { }, getOptimisticUpdate: { args: [], - res: {data: {syncAggregate, attestedHeader: header}}, + res: {data: {syncAggregate, attestedHeader: header, signatureSlot}}, }, getFinalityUpdate: { args: [], @@ -51,6 +52,7 @@ export const testData: GenericServerTestCases = { attestedHeader: header, finalizedHeader: lightClientUpdate.finalizedHeader, finalityBranch: lightClientUpdate.finalityBranch, + signatureSlot: lightClientUpdate.attestedHeader.slot + 1, }, }, }, diff --git a/packages/beacon-node/src/api/impl/events/index.ts b/packages/beacon-node/src/api/impl/events/index.ts index 747c3cd438e5..ac3b750bae44 100644 --- a/packages/beacon-node/src/api/impl/events/index.ts +++ b/packages/beacon-node/src/api/impl/events/index.ts @@ -16,8 +16,9 @@ const chainEventMap = { [routes.events.EventType.finalizedCheckpoint]: ChainEvent.finalized as const, [routes.events.EventType.chainReorg]: ChainEvent.forkChoiceReorg as const, [routes.events.EventType.contributionAndProof]: ChainEvent.contributionAndProof as const, - [routes.events.EventType.lightclientOptimisticUpdate]: ChainEvent.lightclientOptimisticUpdate as const, - [routes.events.EventType.lightclientFinalizedUpdate]: ChainEvent.lightclientFinalizedUpdate as const, + [routes.events.EventType.lightClientOptimisticUpdate]: ChainEvent.lightClientOptimisticUpdate as const, + [routes.events.EventType.lightClientFinalityUpdate]: ChainEvent.lightClientFinalityUpdate as const, + [routes.events.EventType.lightClientUpdate]: ChainEvent.lightClientUpdate as const, }; export function getEventsApi({chain, config}: Pick): routes.events.Api { @@ -60,8 +61,9 @@ export function getEventsApi({chain, config}: Pick [contributionAndProof], - [routes.events.EventType.lightclientOptimisticUpdate]: (headerUpdate) => [headerUpdate], - [routes.events.EventType.lightclientFinalizedUpdate]: (headerUpdate) => [headerUpdate], + [routes.events.EventType.lightClientOptimisticUpdate]: (headerUpdate) => [headerUpdate], + [routes.events.EventType.lightClientFinalityUpdate]: (headerUpdate) => [headerUpdate], + [routes.events.EventType.lightClientUpdate]: (headerUpdate) => [headerUpdate], }; return { diff --git a/packages/beacon-node/src/api/impl/lightclient/index.ts b/packages/beacon-node/src/api/impl/lightclient/index.ts index f5ad48435c81..0acf1f6bbd8c 100644 --- a/packages/beacon-node/src/api/impl/lightclient/index.ts +++ b/packages/beacon-node/src/api/impl/lightclient/index.ts @@ -1,6 +1,8 @@ import {routes} from "@lodestar/api"; import {fromHexString} from "@chainsafe/ssz"; import {ProofType, Tree} from "@chainsafe/persistent-merkle-tree"; +import {SyncPeriod} from "@lodestar/types"; +import {MAX_REQUEST_LIGHT_CLIENT_UPDATES} from "@lodestar/params"; import {ApiModules} from "../types.js"; import {resolveStateId} from "../beacon/state/utils.js"; import {IApiOptions} from "../../options.js"; @@ -41,19 +43,27 @@ export function getLightclientApi( }; }, - // eslint-disable-next-line @typescript-eslint/naming-convention - async getUpdates(start_period, count) { - const periods = Array.from({length: count}, (_ignored, i) => i + start_period); - const updates = await Promise.all(periods.map((period) => chain.lightClientServer.getUpdates(period))); + async getUpdates(startPeriod: SyncPeriod, count: number) { + const maxAllowedCount = Math.min(MAX_REQUEST_LIGHT_CLIENT_UPDATES, count); + const periods = Array.from({length: maxAllowedCount}, (_ignored, i) => i + startPeriod); + const updates = await Promise.all(periods.map((period) => chain.lightClientServer.getUpdate(period))); return {data: updates}; }, async getOptimisticUpdate() { - return {data: await chain.lightClientServer.getOptimisticUpdate()}; + const data = chain.lightClientServer.getOptimisticUpdate(); + if (data === null) { + throw Error("No optimistic update available"); + } + return {data}; }, async getFinalityUpdate() { - return {data: await chain.lightClientServer.getFinalityUpdate()}; + const data = chain.lightClientServer.getFinalityUpdate(); + if (data === null) { + throw Error("No finality update available"); + } + return {data}; }, async getBootstrap(blockRoot) { diff --git a/packages/beacon-node/src/chain/emitter.ts b/packages/beacon-node/src/chain/emitter.ts index 212730dabe22..6bb80018da44 100644 --- a/packages/beacon-node/src/chain/emitter.ts +++ b/packages/beacon-node/src/chain/emitter.ts @@ -88,11 +88,15 @@ export enum ChainEvent { /** * A new lightclient optimistic header update is available to be broadcasted to connected light-clients */ - lightclientOptimisticUpdate = "lightclient:header_update", + lightClientOptimisticUpdate = "lightclient:header_update", /** * A new lightclient finalized header update is available to be broadcasted to connected light-clients */ - lightclientFinalizedUpdate = "lightclient:finalized_update", + lightClientFinalityUpdate = "lightclient:finality_update", + /** + * A new lightclient update is available to be broadcasted to connected light-clients + */ + lightClientUpdate = "lightclient:update", } export type HeadEventData = routes.events.EventData[routes.events.EventType.head]; @@ -114,8 +118,9 @@ export interface IChainEvents { [ChainEvent.forkChoiceJustified]: (checkpoint: CheckpointWithHex) => void; [ChainEvent.forkChoiceFinalized]: (checkpoint: CheckpointWithHex) => void; - [ChainEvent.lightclientOptimisticUpdate]: (optimisticUpdate: routes.events.LightclientOptimisticHeaderUpdate) => void; - [ChainEvent.lightclientFinalizedUpdate]: (finalizedUpdate: routes.events.LightclientFinalizedUpdate) => void; + [ChainEvent.lightClientOptimisticUpdate]: (optimisticUpdate: altair.LightClientOptimisticUpdate) => void; + [ChainEvent.lightClientFinalityUpdate]: (finalizedUpdate: altair.LightClientFinalityUpdate) => void; + [ChainEvent.lightClientUpdate]: (update: altair.LightClientUpdate) => void; } /** diff --git a/packages/beacon-node/src/chain/errors/lightClientError.ts b/packages/beacon-node/src/chain/errors/lightClientError.ts new file mode 100644 index 000000000000..a068d75e15bb --- /dev/null +++ b/packages/beacon-node/src/chain/errors/lightClientError.ts @@ -0,0 +1,30 @@ +import {LodestarError} from "@lodestar/utils"; +import {GossipActionError} from "./gossipValidation.js"; + +export enum LightClientErrorCode { + FINALITY_UPDATE_ALREADY_FORWARDED = "FINALITY_UPDATE_ALREADY_FORWARDED", + OPTIMISTIC_UPDATE_ALREADY_FORWARDED = "OPTIMISTIC_UPDATE_ALREADY_FORWARDED", + FINALITY_UPDATE_RECEIVED_TOO_EARLY = "FINALITY_UPDATE_RECEIVED_TOO_EARLY", + OPTIMISTIC_UPDATE_RECEIVED_TOO_EARLY = "OPTIMISTIC_UPDATE_RECEIVED_TOO_EARLY", + FINALITY_UPDATE_NOT_MATCHING_LOCAL = "FINALITY_UPDATE_NOT_MATCHING_LOCAL", + OPTIMISTIC_UPDATE_NOT_MATCHING_LOCAL = "OPTIMISTIC_UPDATE_NOT_MATCHING_LOCAL", +} +export type LightClientErrorType = + | {code: LightClientErrorCode.FINALITY_UPDATE_ALREADY_FORWARDED} + | {code: LightClientErrorCode.OPTIMISTIC_UPDATE_ALREADY_FORWARDED} + | {code: LightClientErrorCode.FINALITY_UPDATE_RECEIVED_TOO_EARLY} + | {code: LightClientErrorCode.OPTIMISTIC_UPDATE_RECEIVED_TOO_EARLY} + | {code: LightClientErrorCode.FINALITY_UPDATE_NOT_MATCHING_LOCAL} + | {code: LightClientErrorCode.OPTIMISTIC_UPDATE_NOT_MATCHING_LOCAL}; + +export class LightClientError extends GossipActionError {} + +// Errors for the light client server + +export enum LightClientServerErrorCode { + RESOURCE_UNAVAILABLE = "RESOURCE_UNAVALIABLE", +} + +export type LightClientServerErrorType = {code: LightClientServerErrorCode.RESOURCE_UNAVAILABLE}; + +export class LightClientServerError extends LodestarError {} diff --git a/packages/beacon-node/src/chain/lightClient/index.ts b/packages/beacon-node/src/chain/lightClient/index.ts index a24b02919382..a0ae38e635ae 100644 --- a/packages/beacon-node/src/chain/lightClient/index.ts +++ b/packages/beacon-node/src/chain/lightClient/index.ts @@ -2,14 +2,14 @@ import {altair, phase0, Root, RootHex, Slot, ssz, SyncPeriod} from "@lodestar/ty import {IChainForkConfig} from "@lodestar/config"; import {CachedBeaconStateAltair, computeSyncPeriodAtEpoch, computeSyncPeriodAtSlot} from "@lodestar/state-transition"; import {ILogger, MapDef, pruneSetToMax} from "@lodestar/utils"; -import {routes} from "@lodestar/api"; import {BitArray, CompositeViewDU, toHexString} from "@chainsafe/ssz"; -import {SYNC_COMMITTEE_SIZE} from "@lodestar/params"; +import {MIN_SYNC_COMMITTEE_PARTICIPANTS, SYNC_COMMITTEE_SIZE} from "@lodestar/params"; import {IBeaconDb} from "../../db/index.js"; import {IMetrics} from "../../metrics/index.js"; import {ChainEvent, ChainEventEmitter} from "../emitter.js"; import {byteArrayEquals} from "../../util/bytes.js"; import {ZERO_HASH} from "../../constants/index.js"; +import {LightClientServerError, LightClientServerErrorCode} from "../errors/lightClientError.js"; import { getNextSyncCommitteeBranch, getSyncCommitteesWitness, @@ -165,10 +165,10 @@ export class LightClientServer { */ private readonly prevHeadData = new Map(); private checkpointHeaders = new Map(); - private latestHeadUpdate: routes.lightclient.LightclientOptimisticHeaderUpdate | null = null; + private latestHeadUpdate: altair.LightClientOptimisticUpdate | null = null; private readonly zero: Pick; - private finalized: routes.lightclient.LightclientFinalizedUpdate | null = null; + private finalized: altair.LightClientFinalityUpdate | null = null; constructor(private readonly opts: LightClientServerOpts, modules: LightClientServerModules) { const {config, db, metrics, emitter, logger} = modules; @@ -225,7 +225,7 @@ export class LightClientServer { const signedBlockRoot = block.parentRoot; const syncPeriod = computeSyncPeriodAtSlot(block.slot); - this.onSyncAggregate(syncPeriod, block.body.syncAggregate, signedBlockRoot).catch((e) => { + this.onSyncAggregate(syncPeriod, block.body.syncAggregate, block.slot, signedBlockRoot).catch((e) => { this.logger.error("Error onSyncAggregate", {}, e); this.metrics?.lightclientServer.onSyncAggregate.inc({event: "error"}); }); @@ -238,10 +238,13 @@ export class LightClientServer { /** * API ROUTE to get `currentSyncCommittee` and `nextSyncCommittee` from a trusted state root */ - async getBootstrap(blockRoot: Uint8Array): Promise { + async getBootstrap(blockRoot: Uint8Array): Promise { const syncCommitteeWitness = await this.db.syncCommitteeWitness.get(blockRoot); if (!syncCommitteeWitness) { - throw Error(`syncCommitteeWitness not available ${toHexString(blockRoot)}`); + throw new LightClientServerError( + {code: LightClientServerErrorCode.RESOURCE_UNAVAILABLE}, + `syncCommitteeWitness not available ${toHexString(blockRoot)}` + ); } const [currentSyncCommittee, nextSyncCommittee] = await Promise.all([ @@ -249,15 +252,21 @@ export class LightClientServer { this.db.syncCommittee.get(syncCommitteeWitness.nextSyncCommitteeRoot), ]); if (!currentSyncCommittee) { - throw Error("currentSyncCommittee not available"); + throw new LightClientServerError( + {code: LightClientServerErrorCode.RESOURCE_UNAVAILABLE}, + "currentSyncCommittee not available" + ); } if (!nextSyncCommittee) { - throw Error("nextSyncCommittee not available"); + throw new LightClientServerError( + {code: LightClientServerErrorCode.RESOURCE_UNAVAILABLE}, + "nextSyncCommittee not available" + ); } const header = await this.db.checkpointHeader.get(blockRoot); if (!header) { - throw Error("header not available"); + throw new LightClientServerError({code: LightClientServerErrorCode.RESOURCE_UNAVAILABLE}, "header not available"); } return { @@ -274,23 +283,32 @@ export class LightClientServer { * - Has the most bits * - Signed header at the oldest slot */ - async getUpdates(period: SyncPeriod): Promise { + async getUpdate(period: number): Promise { // Signature data const partialUpdate = await this.db.bestPartialLightClientUpdate.get(period); if (!partialUpdate) { - throw Error(`No partialUpdate available for period ${period}`); + throw new LightClientServerError( + {code: LightClientServerErrorCode.RESOURCE_UNAVAILABLE}, + `No partialUpdate available for period ${period}` + ); } const syncCommitteeWitnessBlockRoot = partialUpdate.blockRoot; const syncCommitteeWitness = await this.db.syncCommitteeWitness.get(syncCommitteeWitnessBlockRoot); if (!syncCommitteeWitness) { - throw Error(`finalizedBlockRoot not available ${toHexString(syncCommitteeWitnessBlockRoot)}`); + throw new LightClientServerError( + {code: LightClientServerErrorCode.RESOURCE_UNAVAILABLE}, + `finalizedBlockRoot not available ${toHexString(syncCommitteeWitnessBlockRoot)}` + ); } const nextSyncCommittee = await this.db.syncCommittee.get(syncCommitteeWitness.nextSyncCommitteeRoot); if (!nextSyncCommittee) { - throw Error("nextSyncCommittee not available"); + throw new LightClientServerError( + {code: LightClientServerErrorCode.RESOURCE_UNAVAILABLE}, + "nextSyncCommittee not available" + ); } if (partialUpdate.isFinalized) { @@ -301,6 +319,7 @@ export class LightClientServer { finalizedHeader: partialUpdate.finalizedHeader, finalityBranch: partialUpdate.finalityBranch, syncAggregate: partialUpdate.syncAggregate, + signatureSlot: partialUpdate.signatureSlot, }; } else { return { @@ -310,6 +329,7 @@ export class LightClientServer { finalizedHeader: this.zero.finalizedHeader, finalityBranch: this.zero.finalityBranch, syncAggregate: partialUpdate.syncAggregate, + signatureSlot: partialUpdate.signatureSlot, }; } } @@ -318,18 +338,11 @@ export class LightClientServer { * API ROUTE to poll LightclientHeaderUpdate. * Clients should use the SSE type `light_client_optimistic_update` if available */ - async getOptimisticUpdate(): Promise { - if (this.latestHeadUpdate === null) { - throw Error("No latest header update available"); - } + getOptimisticUpdate(): altair.LightClientOptimisticUpdate | null { return this.latestHeadUpdate; } - async getFinalityUpdate(): Promise { - // Signature data - if (this.finalized === null) { - throw Error("No latest header update available"); - } + getFinalityUpdate(): altair.LightClientFinalityUpdate | null { return this.finalized; } @@ -450,6 +463,7 @@ export class LightClientServer { private async onSyncAggregate( syncPeriod: SyncPeriod, syncAggregate: altair.SyncAggregate, + signatureSlot: Slot, signedBlockRoot: Root ): Promise { this.metrics?.lightclientServer.onSyncAggregate.inc({event: "processed"}); @@ -470,15 +484,26 @@ export class LightClientServer { return; } - const headerUpdate: routes.lightclient.LightclientOptimisticHeaderUpdate = { + const headerUpdate: altair.LightClientOptimisticUpdate = { attestedHeader: attestedData.attestedHeader, syncAggregate, + signatureSlot, }; + const syncAggregateParticipation = sumBits(syncAggregate.syncCommitteeBits); + + if (syncAggregateParticipation < MIN_SYNC_COMMITTEE_PARTICIPANTS) { + this.logger.debug("sync committee below required MIN_SYNC_COMMITTEE_PARTICIPANTS", { + syncPeriod, + attestedPeriod, + }); + this.metrics?.lightclientServer.onSyncAggregate.inc({event: "ignore_sync_committee_low"}); + return; + } + // Emit update - // - At the earliest: 6 second after the slot start - // - After a new update has INCREMENT_THRESHOLD == 32 bits more than the previous emitted threshold - this.emitter.emit(ChainEvent.lightclientOptimisticUpdate, headerUpdate); + // Note: Always emit optimistic update even if we have emitted one with higher or equal attested_header.slot + this.emitter.emit(ChainEvent.lightClientOptimisticUpdate, headerUpdate); // Persist latest best update for getLatestHeadUpdate() // TODO: Once SyncAggregate are constructed from P2P too, count bits to decide "best" @@ -490,25 +515,29 @@ export class LightClientServer { if (attestedData.isFinalized) { const finalizedCheckpointRoot = attestedData.finalizedCheckpoint.root as Uint8Array; const finalizedHeader = await this.getFinalizedHeader(finalizedCheckpointRoot); + if ( finalizedHeader && (!this.finalized || finalizedHeader.slot > this.finalized.finalizedHeader.slot || - sumBits(syncAggregate.syncCommitteeBits) > sumBits(this.finalized.syncAggregate.syncCommitteeBits)) + syncAggregateParticipation > sumBits(this.finalized.syncAggregate.syncCommitteeBits)) ) { this.finalized = { attestedHeader: attestedData.attestedHeader, finalizedHeader, syncAggregate, finalityBranch: attestedData.finalityBranch, + signatureSlot, }; - this.emitter.emit(ChainEvent.lightclientFinalizedUpdate, this.finalized); this.metrics?.lightclientServer.onSyncAggregate.inc({event: "update_latest_finalized_update"}); + + // Note: Ignores gossip rule to always emit finaly_update with higher finalized_header.slot, for simplicity + this.emitter.emit(ChainEvent.lightClientFinalityUpdate, this.finalized); } } // Check if this update is better, otherwise ignore - await this.maybeStoreNewBestPartialUpdate(syncPeriod, syncAggregate, attestedData); + await this.maybeStoreNewBestPartialUpdate(syncPeriod, syncAggregate, signatureSlot, attestedData); } /** @@ -518,6 +547,7 @@ export class LightClientServer { private async maybeStoreNewBestPartialUpdate( syncPeriod: SyncPeriod, syncAggregate: altair.SyncAggregate, + signatureSlot: Slot, attestedData: SyncAttestedData ): Promise { const prevBestUpdate = await this.db.bestPartialLightClientUpdate.get(syncPeriod); @@ -535,13 +565,13 @@ export class LightClientServer { const finalizedHeader = await this.getFinalizedHeader(finalizedCheckpointRoot); if (finalizedHeader && computeSyncPeriodAtSlot(finalizedHeader.slot) == syncPeriod) { // If finalizedHeader is available (should be most times) create a finalized update - newPartialUpdate = {...attestedData, finalizedHeader, syncAggregate}; + newPartialUpdate = {...attestedData, finalizedHeader, syncAggregate, signatureSlot}; } else { // If finalizedHeader is not available (happens on startup) create a non-finalized update - newPartialUpdate = {...attestedData, isFinalized: false, syncAggregate}; + newPartialUpdate = {...attestedData, isFinalized: false, syncAggregate, signatureSlot}; } } else { - newPartialUpdate = {...attestedData, syncAggregate}; + newPartialUpdate = {...attestedData, syncAggregate, signatureSlot}; } // attestedData and the block of syncAggregate may not be in same sync period diff --git a/packages/beacon-node/src/chain/lightClient/types.ts b/packages/beacon-node/src/chain/lightClient/types.ts index 9258c444a837..a751ee8c4ec3 100644 --- a/packages/beacon-node/src/chain/lightClient/types.ts +++ b/packages/beacon-node/src/chain/lightClient/types.ts @@ -1,4 +1,4 @@ -import {altair, phase0} from "@lodestar/types"; +import {altair, phase0, Slot} from "@lodestar/types"; /** * We aren't creating the sync committee proofs separately because our ssz library automatically adds leaves to composite types, @@ -44,6 +44,7 @@ export type PartialLightClientUpdateFinalized = { finalizedCheckpoint: phase0.Checkpoint; finalizedHeader: phase0.BeaconBlockHeader; syncAggregate: altair.SyncAggregate; + signatureSlot: Slot; }; export type PartialLightClientUpdateNonFinalized = { @@ -53,6 +54,7 @@ export type PartialLightClientUpdateNonFinalized = { blockRoot: Uint8Array; // Finalized data syncAggregate: altair.SyncAggregate; + signatureSlot: Slot; }; export type PartialLightClientUpdate = PartialLightClientUpdateFinalized | PartialLightClientUpdateNonFinalized; diff --git a/packages/beacon-node/src/chain/validation/lightClientFinalityUpdate.ts b/packages/beacon-node/src/chain/validation/lightClientFinalityUpdate.ts new file mode 100644 index 000000000000..d4c511d5bf43 --- /dev/null +++ b/packages/beacon-node/src/chain/validation/lightClientFinalityUpdate.ts @@ -0,0 +1,42 @@ +import {IChainForkConfig} from "@lodestar/config"; +import {altair, ssz} from "@lodestar/types"; +import {IBeaconChain} from "../interface.js"; +import {LightClientError, LightClientErrorCode} from "../errors/lightClientError.js"; +import {GossipAction} from "../errors/index.js"; +import {updateReceivedTooEarly} from "./lightClientOptimisticUpdate.js"; + +// https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/light-client/p2p-interface.md#light_client_finality_update +export function validateLightClientFinalityUpdate( + config: IChainForkConfig, + chain: IBeaconChain, + gossipedFinalityUpdate: altair.LightClientFinalityUpdate +): void { + // [IGNORE] No other finality_update with a lower or equal finalized_header.slot was already forwarded on the network + const gossipedFinalitySlot = gossipedFinalityUpdate.finalizedHeader.slot; + const localFinalityUpdate = chain.lightClientServer.getFinalityUpdate(); + + if (localFinalityUpdate && gossipedFinalitySlot <= localFinalityUpdate.finalizedHeader.slot) { + throw new LightClientError(GossipAction.IGNORE, { + code: LightClientErrorCode.FINALITY_UPDATE_ALREADY_FORWARDED, + }); + } + + // [IGNORE] The finality_update is received after the block at signature_slot was given enough time to propagate + // through the network -- i.e. validate that one-third of finality_update.signature_slot has transpired + // (SECONDS_PER_SLOT / INTERVALS_PER_SLOT seconds after the start of the slot, with a MAXIMUM_GOSSIP_CLOCK_DISPARITY allowance) + if (updateReceivedTooEarly(config, chain.genesisTime, gossipedFinalityUpdate)) { + throw new LightClientError(GossipAction.IGNORE, { + code: LightClientErrorCode.FINALITY_UPDATE_RECEIVED_TOO_EARLY, + }); + } + + // [IGNORE] The received finality_update matches the locally computed one exactly + if ( + localFinalityUpdate === null || + !ssz.altair.LightClientFinalityUpdate.equals(gossipedFinalityUpdate, localFinalityUpdate) + ) { + throw new LightClientError(GossipAction.IGNORE, { + code: LightClientErrorCode.FINALITY_UPDATE_NOT_MATCHING_LOCAL, + }); + } +} diff --git a/packages/beacon-node/src/chain/validation/lightClientOptimisticUpdate.ts b/packages/beacon-node/src/chain/validation/lightClientOptimisticUpdate.ts new file mode 100644 index 000000000000..90f00ac3522e --- /dev/null +++ b/packages/beacon-node/src/chain/validation/lightClientOptimisticUpdate.ts @@ -0,0 +1,64 @@ +import {altair, ssz} from "@lodestar/types"; +import {IChainForkConfig} from "@lodestar/config"; +import {computeTimeAtSlot} from "@lodestar/state-transition"; +import {IBeaconChain} from "../interface.js"; +import {LightClientError, LightClientErrorCode} from "../errors/lightClientError.js"; +import {GossipAction} from "../errors/index.js"; +import {MAXIMUM_GOSSIP_CLOCK_DISPARITY} from "../../constants/index.js"; + +// https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/light-client/p2p-interface.md#light_client_optimistic_update +export function validateLightClientOptimisticUpdate( + config: IChainForkConfig, + chain: IBeaconChain, + gossipedOptimisticUpdate: altair.LightClientOptimisticUpdate +): void { + // [IGNORE] No other optimistic_update with a lower or equal attested_header.slot was already forwarded on the network + const gossipedAttestedSlot = gossipedOptimisticUpdate.attestedHeader.slot; + const localOptimisticUpdate = chain.lightClientServer.getOptimisticUpdate(); + + if (localOptimisticUpdate && gossipedAttestedSlot <= localOptimisticUpdate.attestedHeader.slot) { + throw new LightClientError(GossipAction.IGNORE, { + code: LightClientErrorCode.OPTIMISTIC_UPDATE_ALREADY_FORWARDED, + }); + } + + // [IGNORE] The optimistic_update is received after the block at signature_slot was given enough time to propagate + // through the network -- i.e. validate that one-third of optimistic_update.signature_slot has transpired + // (SECONDS_PER_SLOT / INTERVALS_PER_SLOT seconds after the start of the slot, with a MAXIMUM_GOSSIP_CLOCK_DISPARITY allowance) + if (updateReceivedTooEarly(config, chain.genesisTime, gossipedOptimisticUpdate)) { + throw new LightClientError(GossipAction.IGNORE, { + code: LightClientErrorCode.OPTIMISTIC_UPDATE_RECEIVED_TOO_EARLY, + }); + } + + // [IGNORE] The received optimistic_update matches the locally computed one exactly + if ( + localOptimisticUpdate === null || + !ssz.altair.LightClientOptimisticUpdate.equals(gossipedOptimisticUpdate, localOptimisticUpdate) + ) { + throw new LightClientError(GossipAction.IGNORE, { + code: LightClientErrorCode.OPTIMISTIC_UPDATE_NOT_MATCHING_LOCAL, + }); + } +} + +/** + * Returns true, if the spec condition below triggers an IGNORE. + * + * Sig +1/3 time + * -----|----- + * xxx|------- (x is not okay) + * + * [IGNORE] The *update is received after the block at signature_slot was given enough time to propagate + * through the network -- i.e. validate that one-third of *update.signature_slot has transpired + * (SECONDS_PER_SLOT / INTERVALS_PER_SLOT seconds after the start of the slot, with a MAXIMUM_GOSSIP_CLOCK_DISPARITY allowance) + */ +export function updateReceivedTooEarly( + config: IChainForkConfig, + genesisTime: number, + update: Pick +): boolean { + const signatureSlot13TimestampMs = computeTimeAtSlot(config, update.signatureSlot + 1 / 3, genesisTime) * 1000; + const earliestAllowedTimestampMs = signatureSlot13TimestampMs - MAXIMUM_GOSSIP_CLOCK_DISPARITY; + return Date.now() < earliestAllowedTimestampMs; +} diff --git a/packages/beacon-node/src/db/repositories/lightclientBestPartialUpdate.ts b/packages/beacon-node/src/db/repositories/lightclientBestPartialUpdate.ts index 1f7aab481ff2..3845b3e647c4 100644 --- a/packages/beacon-node/src/db/repositories/lightclientBestPartialUpdate.ts +++ b/packages/beacon-node/src/db/repositories/lightclientBestPartialUpdate.ts @@ -20,6 +20,7 @@ export class BestPartialLightClientUpdateRepository extends Repository { + const fork = this.config.getForkName(lightClientFinalityUpdate.signatureSlot); + await this.publishObject( + {type: GossipType.light_client_finality_update, fork}, + lightClientFinalityUpdate + ); + } + + async publishLightClientOptimisticUpdate( + lightClientOptimisitcUpdate: altair.LightClientOptimisticUpdate + ): Promise { + const fork = this.config.getForkName(lightClientOptimisitcUpdate.signatureSlot); + await this.publishObject( + {type: GossipType.light_client_optimistic_update, fork}, + lightClientOptimisitcUpdate + ); + } + private getGossipTopicString(topic: GossipTopic): string { return stringifyGossipTopic(this.config, topic); } diff --git a/packages/beacon-node/src/network/gossip/handlers/index.ts b/packages/beacon-node/src/network/gossip/handlers/index.ts index d2726b8d2f82..7b55b32c1e7e 100644 --- a/packages/beacon-node/src/network/gossip/handlers/index.ts +++ b/packages/beacon-node/src/network/gossip/handlers/index.ts @@ -29,6 +29,8 @@ import { import {INetwork} from "../../interface.js"; import {NetworkEvent} from "../../events.js"; import {PeerAction} from "../../peers/index.js"; +import {validateLightClientFinalityUpdate} from "../../../chain/validation/lightClientFinalityUpdate.js"; +import {validateLightClientOptimisticUpdate} from "../../../chain/validation/lightClientOptimisticUpdate.js"; /** * Gossip handler options as part of network options @@ -285,6 +287,14 @@ export function getGossipHandlers(modules: ValidatorFnsModules, options: GossipH logger.error("Error adding to syncCommittee pool", {subnet}, e as Error); } }, + + [GossipType.light_client_finality_update]: async (lightClientFinalityUpdate) => { + validateLightClientFinalityUpdate(config, chain, lightClientFinalityUpdate); + }, + + [GossipType.light_client_optimistic_update]: async (lightClientOptimisticUpdate) => { + validateLightClientOptimisticUpdate(config, chain, lightClientOptimisticUpdate); + }, }; } diff --git a/packages/beacon-node/src/network/gossip/interface.ts b/packages/beacon-node/src/network/gossip/interface.ts index 974e8a49ae5d..df9b711c1a4d 100644 --- a/packages/beacon-node/src/network/gossip/interface.ts +++ b/packages/beacon-node/src/network/gossip/interface.ts @@ -22,6 +22,8 @@ export enum GossipType { // altair sync_committee_contribution_and_proof = "sync_committee_contribution_and_proof", sync_committee = "sync_committee", + light_client_finality_update = "light_client_finality_update", + light_client_optimistic_update = "light_client_optimistic_update", } export enum GossipEncoding { @@ -48,6 +50,8 @@ export type GossipTopicTypeMap = { type: GossipType.sync_committee_contribution_and_proof; }; [GossipType.sync_committee]: {type: GossipType.sync_committee; subnet: number}; + [GossipType.light_client_finality_update]: {type: GossipType.light_client_finality_update}; + [GossipType.light_client_optimistic_update]: {type: GossipType.light_client_optimistic_update}; }; export type GossipTopicMap = { @@ -68,6 +72,8 @@ export type GossipTypeMap = { [GossipType.attester_slashing]: phase0.AttesterSlashing; [GossipType.sync_committee_contribution_and_proof]: altair.SignedContributionAndProof; [GossipType.sync_committee]: altair.SyncCommitteeMessage; + [GossipType.light_client_finality_update]: altair.LightClientFinalityUpdate; + [GossipType.light_client_optimistic_update]: altair.LightClientOptimisticUpdate; }; export type GossipFnByType = { @@ -81,6 +87,12 @@ export type GossipFnByType = { signedContributionAndProof: altair.SignedContributionAndProof ) => Promise | void; [GossipType.sync_committee]: (syncCommittee: altair.SyncCommitteeMessage) => Promise | void; + [GossipType.light_client_finality_update]: ( + lightClientFinalityUpdate: altair.LightClientFinalityUpdate + ) => Promise | void; + [GossipType.light_client_optimistic_update]: ( + lightClientOptimisticUpdate: altair.LightClientOptimisticUpdate + ) => Promise | void; }; export type GossipFn = GossipFnByType[keyof GossipFnByType]; diff --git a/packages/beacon-node/src/network/gossip/topic.ts b/packages/beacon-node/src/network/gossip/topic.ts index 2cc3a185c5fd..97a962b40964 100644 --- a/packages/beacon-node/src/network/gossip/topic.ts +++ b/packages/beacon-node/src/network/gossip/topic.ts @@ -1,6 +1,6 @@ import {ssz} from "@lodestar/types"; import {IForkDigestContext} from "@lodestar/config"; -import {GossipType, GossipTopic, GossipEncoding} from "./interface.js"; +import {GossipEncoding, GossipTopic, GossipType} from "./interface.js"; import {DEFAULT_ENCODING} from "./constants.js"; export interface IGossipTopicCache { @@ -56,6 +56,8 @@ function stringifyGossipTopicType(topic: GossipTopic): string { case GossipType.proposer_slashing: case GossipType.attester_slashing: case GossipType.sync_committee_contribution_and_proof: + case GossipType.light_client_finality_update: + case GossipType.light_client_optimistic_update: return topic.type; case GossipType.beacon_attestation: case GossipType.sync_committee: @@ -83,8 +85,10 @@ export function getGossipSSZType(topic: GossipTopic) { return ssz.altair.SignedContributionAndProof; case GossipType.sync_committee: return ssz.altair.SyncCommitteeMessage; - default: - throw new Error(`No ssz gossip type for ${(topic as GossipTopic).type}`); + case GossipType.light_client_optimistic_update: + return ssz.altair.LightClientOptimisticUpdate; + case GossipType.light_client_finality_update: + return ssz.altair.LightClientFinalityUpdate; } } @@ -119,6 +123,8 @@ export function parseGossipTopic(forkDigestContext: IForkDigestContext, topicStr case GossipType.proposer_slashing: case GossipType.attester_slashing: case GossipType.sync_committee_contribution_and_proof: + case GossipType.light_client_finality_update: + case GossipType.light_client_optimistic_update: return {type: gossipTypeStr, fork, encoding}; } diff --git a/packages/beacon-node/src/network/gossip/validation/queue.ts b/packages/beacon-node/src/network/gossip/validation/queue.ts index 096d98fdb38c..a88b4c355f79 100644 --- a/packages/beacon-node/src/network/gossip/validation/queue.ts +++ b/packages/beacon-node/src/network/gossip/validation/queue.ts @@ -17,6 +17,8 @@ const gossipQueueOpts: {[K in GossipType]: Pick(); @@ -60,6 +61,7 @@ export class Network implements INetwork { this.libp2p = libp2p; this.logger = logger; this.config = config; + this.signal = signal; this.clock = chain.clock; this.chain = chain; this.peersData = new PeersData(); @@ -123,12 +125,16 @@ export class Network implements INetwork { ); this.chain.emitter.on(ChainEvent.clockEpoch, this.onEpoch); + this.chain.emitter.on(ChainEvent.lightClientFinalityUpdate, this.onLightClientFinalityUpdate); + this.chain.emitter.on(ChainEvent.lightClientOptimisticUpdate, this.onLightClientOptimisticUpdate); modules.signal.addEventListener("abort", this.close.bind(this), {once: true}); } /** Destroy this instance. Can only be called once. */ close(): void { this.chain.emitter.off(ChainEvent.clockEpoch, this.onEpoch); + this.chain.emitter.off(ChainEvent.lightClientFinalityUpdate, this.onLightClientFinalityUpdate); + this.chain.emitter.off(ChainEvent.lightClientOptimisticUpdate, this.onLightClientOptimisticUpdate); } async start(): Promise { @@ -315,6 +321,8 @@ export class Network implements INetwork { // Any fork after altair included if (fork !== ForkName.phase0) { this.gossip.subscribeTopic({type: GossipType.sync_committee_contribution_and_proof, fork}); + this.gossip.subscribeTopic({type: GossipType.light_client_optimistic_update, fork}); + this.gossip.subscribeTopic({type: GossipType.light_client_finality_update, fork}); } if (this.opts.subscribeAllSubnets) { @@ -339,6 +347,8 @@ export class Network implements INetwork { // Any fork after altair included if (fork !== ForkName.phase0) { this.gossip.unsubscribeTopic({type: GossipType.sync_committee_contribution_and_proof, fork}); + this.gossip.unsubscribeTopic({type: GossipType.light_client_optimistic_update, fork}); + this.gossip.unsubscribeTopic({type: GossipType.light_client_finality_update, fork}); } if (this.opts.subscribeAllSubnets) { @@ -350,4 +360,54 @@ export class Network implements INetwork { } } }; + + private onLightClientFinalityUpdate = async (finalityUpdate: altair.LightClientFinalityUpdate): Promise => { + if (this.hasAttachedSyncCommitteeMember()) { + try { + // messages SHOULD be broadcast after one-third of slot has transpired + // https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/light-client/p2p-interface.md#sync-committee + await this.waitOneThirdOfSlot(finalityUpdate.signatureSlot); + return await this.gossip.publishLightClientFinalityUpdate(finalityUpdate); + } catch (e) { + // Non-mandatory route on most of network as of Oct 2022. May not have found any peers on topic yet + // Remove once https://github.com/ChainSafe/js-libp2p-gossipsub/issues/367 + if (!isPublishToZeroPeersError(e as Error)) { + this.logger.debug("Error on BeaconGossipHandler.onLightclientFinalityUpdate", {}, e as Error); + } + } + } + }; + + private onLightClientOptimisticUpdate = async ( + optimisticUpdate: altair.LightClientOptimisticUpdate + ): Promise => { + if (this.hasAttachedSyncCommitteeMember()) { + try { + // messages SHOULD be broadcast after one-third of slot has transpired + // https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/light-client/p2p-interface.md#sync-committee + await this.waitOneThirdOfSlot(optimisticUpdate.signatureSlot); + return await this.gossip.publishLightClientOptimisticUpdate(optimisticUpdate); + } catch (e) { + // Non-mandatory route on most of network as of Oct 2022. May not have found any peers on topic yet + // Remove once https://github.com/ChainSafe/js-libp2p-gossipsub/issues/367 + if (!isPublishToZeroPeersError(e as Error)) { + this.logger.debug("Error on BeaconGossipHandler.onLightclientOptimisticUpdate", {}, e as Error); + } + } + } + }; + + private waitOneThirdOfSlot = async (slot: number): Promise => { + const secAtSlot = computeTimeAtSlot(this.config, slot + 1 / 3, this.chain.genesisTime); + const msToSlot = secAtSlot * 1000 - Date.now(); + await sleep(msToSlot, this.signal); + }; + + // full nodes with at least one validator assigned to the current sync committee at the block's slot SHOULD broadcast + // This prevents flooding the network by restricting full nodes that initially + // publish to at most 512 (max size of active sync committee). + // https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/light-client/p2p-interface.md#sync-committee + private hasAttachedSyncCommitteeMember(): boolean { + return this.syncnetsService.getActiveSubnets().length > 0; + } } diff --git a/packages/beacon-node/src/network/reqresp/encoders/responseEncode.ts b/packages/beacon-node/src/network/reqresp/encoders/responseEncode.ts index a921611f1f05..fda4fc8b8fd9 100644 --- a/packages/beacon-node/src/network/reqresp/encoders/responseEncode.ts +++ b/packages/beacon-node/src/network/reqresp/encoders/responseEncode.ts @@ -4,15 +4,15 @@ import {RespStatus, RpcResponseStatusError} from "../../../constants/index.js"; import {writeEncodedPayload} from "../encodingStrategies/index.js"; import {encodeErrorMessage} from "../utils/index.js"; import { - Method, - Protocol, - OutgoingResponseBody, - ResponseTypedContainer, - OutgoingResponseBodyByMethod, ContextBytesType, contextBytesTypeByProtocol, - IncomingResponseBodyByMethod, getOutgoingSerializerByMethod, + IncomingResponseBodyByMethod, + Method, + OutgoingResponseBody, + OutgoingResponseBodyByMethod, + Protocol, + ResponseTypedContainer, } from "../types.js"; /** @@ -107,5 +107,10 @@ export function getForkNameFromResponseBody( case Method.BeaconBlocksByRange: case Method.BeaconBlocksByRoot: return config.getForkName(requestTyped.body.slot); + case Method.LightClientBootstrap: + case Method.LightClientUpdate: + case Method.LightClientFinalityUpdate: + case Method.LightClientOptimisticUpdate: + return ForkName.altair; } } diff --git a/packages/beacon-node/src/network/reqresp/handlers/index.ts b/packages/beacon-node/src/network/reqresp/handlers/index.ts index 391baddfdd18..5139bdd43523 100644 --- a/packages/beacon-node/src/network/reqresp/handlers/index.ts +++ b/packages/beacon-node/src/network/reqresp/handlers/index.ts @@ -1,14 +1,22 @@ -import {phase0} from "@lodestar/types"; +import {altair, phase0, Root} from "@lodestar/types"; import {IBeaconChain} from "../../../chain/index.js"; import {IBeaconDb} from "../../../db/index.js"; import {ReqRespBlockResponse} from "../types.js"; import {onBeaconBlocksByRange} from "./beaconBlocksByRange.js"; import {onBeaconBlocksByRoot} from "./beaconBlocksByRoot.js"; +import {onLightClientBootstrap} from "./lightClientBootstrap.js"; +import {onLightClientUpdatesByRange} from "./lightClientUpdatesByRange.js"; +import {onLightClientFinalityUpdate} from "./lightClientFinalityUpdate.js"; +import {onLightClientOptimisticUpdate} from "./lightClientOptimisticUpdate.js"; export type ReqRespHandlers = { onStatus(): AsyncIterable; onBeaconBlocksByRange(req: phase0.BeaconBlocksByRangeRequest): AsyncIterable; onBeaconBlocksByRoot(req: phase0.BeaconBlocksByRootRequest): AsyncIterable; + onLightClientBootstrap(req: Root): AsyncIterable; + onLightClientUpdatesByRange(req: altair.LightClientUpdatesByRange): AsyncIterable; + onLightClientFinalityUpdate(): AsyncIterable; + onLightClientOptimisticUpdate(): AsyncIterable; }; /** @@ -20,13 +28,23 @@ export function getReqRespHandlers({db, chain}: {db: IBeaconDb; chain: IBeaconCh async *onStatus() { yield chain.getStatus(); }, - async *onBeaconBlocksByRange(req) { yield* onBeaconBlocksByRange(req, chain, db); }, - async *onBeaconBlocksByRoot(req) { yield* onBeaconBlocksByRoot(req, chain, db); }, + async *onLightClientBootstrap(req) { + yield* onLightClientBootstrap(req, chain); + }, + async *onLightClientUpdatesByRange(req) { + yield* onLightClientUpdatesByRange(req, chain); + }, + async *onLightClientFinalityUpdate() { + yield* onLightClientFinalityUpdate(chain); + }, + async *onLightClientOptimisticUpdate() { + yield* onLightClientOptimisticUpdate(chain); + }, }; } diff --git a/packages/beacon-node/src/network/reqresp/handlers/lightClientBootstrap.ts b/packages/beacon-node/src/network/reqresp/handlers/lightClientBootstrap.ts new file mode 100644 index 000000000000..f1555aa5561b --- /dev/null +++ b/packages/beacon-node/src/network/reqresp/handlers/lightClientBootstrap.ts @@ -0,0 +1,20 @@ +import {altair, Root} from "@lodestar/types"; +import {IBeaconChain} from "../../../chain/index.js"; +import {ResponseError} from "../response/index.js"; +import {RespStatus} from "../../../constants/index.js"; +import {LightClientServerError, LightClientServerErrorCode} from "../../../chain/errors/lightClientError.js"; + +export async function* onLightClientBootstrap( + requestBody: Root, + chain: IBeaconChain +): AsyncIterable { + try { + yield await chain.lightClientServer.getBootstrap(requestBody); + } catch (e) { + if ((e as LightClientServerError).type?.code === LightClientServerErrorCode.RESOURCE_UNAVAILABLE) { + throw new ResponseError(RespStatus.RESOURCE_UNAVAILABLE, (e as Error).message); + } else { + throw new ResponseError(RespStatus.SERVER_ERROR, (e as Error).message); + } + } +} diff --git a/packages/beacon-node/src/network/reqresp/handlers/lightClientFinalityUpdate.ts b/packages/beacon-node/src/network/reqresp/handlers/lightClientFinalityUpdate.ts new file mode 100644 index 000000000000..f6dc16aab754 --- /dev/null +++ b/packages/beacon-node/src/network/reqresp/handlers/lightClientFinalityUpdate.ts @@ -0,0 +1,15 @@ +import {altair} from "@lodestar/types"; +import {IBeaconChain} from "../../../chain/index.js"; +import {ResponseError} from "../response/index.js"; +import {RespStatus} from "../../../constants/index.js"; + +export async function* onLightClientFinalityUpdate( + chain: IBeaconChain +): AsyncIterable { + const finalityUpdate = chain.lightClientServer.getFinalityUpdate(); + if (finalityUpdate === null) { + throw new ResponseError(RespStatus.RESOURCE_UNAVAILABLE, "No latest finality update available"); + } else { + yield finalityUpdate; + } +} diff --git a/packages/beacon-node/src/network/reqresp/handlers/lightClientOptimisticUpdate.ts b/packages/beacon-node/src/network/reqresp/handlers/lightClientOptimisticUpdate.ts new file mode 100644 index 000000000000..4a5dcf1be016 --- /dev/null +++ b/packages/beacon-node/src/network/reqresp/handlers/lightClientOptimisticUpdate.ts @@ -0,0 +1,15 @@ +import {altair} from "@lodestar/types"; +import {IBeaconChain} from "../../../chain/index.js"; +import {ResponseError} from "../response/index.js"; +import {RespStatus} from "../../../constants/index.js"; + +export async function* onLightClientOptimisticUpdate( + chain: IBeaconChain +): AsyncIterable { + const optimisticUpdate = chain.lightClientServer.getOptimisticUpdate(); + if (optimisticUpdate === null) { + throw new ResponseError(RespStatus.RESOURCE_UNAVAILABLE, "No latest optimistic update available"); + } else { + yield optimisticUpdate; + } +} diff --git a/packages/beacon-node/src/network/reqresp/handlers/lightClientUpdatesByRange.ts b/packages/beacon-node/src/network/reqresp/handlers/lightClientUpdatesByRange.ts new file mode 100644 index 000000000000..884a196da8a6 --- /dev/null +++ b/packages/beacon-node/src/network/reqresp/handlers/lightClientUpdatesByRange.ts @@ -0,0 +1,24 @@ +import {altair} from "@lodestar/types"; +import {MAX_REQUEST_LIGHT_CLIENT_UPDATES} from "@lodestar/params"; +import {IBeaconChain} from "../../../chain/index.js"; +import {LightClientServerError, LightClientServerErrorCode} from "../../../chain/errors/lightClientError.js"; +import {ResponseError} from "../response/errors.js"; +import {RespStatus} from "../../../constants/network.js"; + +export async function* onLightClientUpdatesByRange( + requestBody: altair.LightClientUpdatesByRange, + chain: IBeaconChain +): AsyncIterable { + const count = Math.min(MAX_REQUEST_LIGHT_CLIENT_UPDATES, requestBody.count); + for (let period = requestBody.startPeriod; period < requestBody.startPeriod + count; period++) { + try { + yield await chain.lightClientServer.getUpdate(period); + } catch (e) { + if ((e as LightClientServerError).type?.code === LightClientServerErrorCode.RESOURCE_UNAVAILABLE) { + throw new ResponseError(RespStatus.RESOURCE_UNAVAILABLE, (e as Error).message); + } else { + throw new ResponseError(RespStatus.SERVER_ERROR, (e as Error).message); + } + } + } +} diff --git a/packages/beacon-node/src/network/reqresp/interface.ts b/packages/beacon-node/src/network/reqresp/interface.ts index eb8355f9bfcc..296591775587 100644 --- a/packages/beacon-node/src/network/reqresp/interface.ts +++ b/packages/beacon-node/src/network/reqresp/interface.ts @@ -2,7 +2,7 @@ import {Libp2p} from "libp2p"; import {PeerId} from "@libp2p/interface-peer-id"; import {ForkName} from "@lodestar/params"; import {IBeaconConfig} from "@lodestar/config"; -import {allForks, phase0} from "@lodestar/types"; +import {allForks, altair, phase0} from "@lodestar/types"; import {ILogger} from "@lodestar/utils"; import {IPeerRpcScoreStore} from "../peers/index.js"; import {MetadataController} from "../metadata.js"; @@ -25,6 +25,10 @@ export interface IReqResp { ): Promise; beaconBlocksByRoot(peerId: PeerId, request: phase0.BeaconBlocksByRootRequest): Promise; pruneOnPeerDisconnect(peerId: PeerId): void; + lightClientBootstrap(peerId: PeerId, request: Uint8Array): Promise; + lightClientOptimisticUpdate(peerId: PeerId): Promise; + lightClientFinalityUpdate(peerId: PeerId): Promise; + lightClientUpdate(peerId: PeerId, request: altair.LightClientUpdatesByRange): Promise; } export interface IReqRespModules { diff --git a/packages/beacon-node/src/network/reqresp/reqResp.ts b/packages/beacon-node/src/network/reqresp/reqResp.ts index bf2ba6329be9..ed89583151ef 100644 --- a/packages/beacon-node/src/network/reqresp/reqResp.ts +++ b/packages/beacon-node/src/network/reqresp/reqResp.ts @@ -4,7 +4,7 @@ import {PeerId} from "@libp2p/interface-peer-id"; import {Connection, Stream} from "@libp2p/interface-connection"; import {ForkName} from "@lodestar/params"; import {IBeaconConfig} from "@lodestar/config"; -import {allForks, phase0} from "@lodestar/types"; +import {allForks, altair, phase0} from "@lodestar/types"; import {ILogger} from "@lodestar/utils"; import {RespStatus, timeoutOptions} from "../../constants/index.js"; import {PeersData} from "../peers/peersData.js"; @@ -38,6 +38,7 @@ export type IReqRespOptions = Partial; * Implementation of Ethereum Consensus p2p Req/Resp domain. * For the spec that this code is based on, see: * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/p2p-interface.md#the-reqresp-domain + * https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/light-client/p2p-interface.md#the-reqresp-domain */ export class ReqResp implements IReqResp { private config: IBeaconConfig; @@ -141,6 +142,46 @@ export class ReqResp implements IReqResp { this.inboundRateLimiter.prune(peerId); } + async lightClientBootstrap(peerId: PeerId, request: Uint8Array): Promise { + return await this.sendRequest( + peerId, + Method.LightClientBootstrap, + [Version.V1], + request + ); + } + + async lightClientOptimisticUpdate(peerId: PeerId): Promise { + return await this.sendRequest( + peerId, + Method.LightClientOptimisticUpdate, + [Version.V1], + null + ); + } + + async lightClientFinalityUpdate(peerId: PeerId): Promise { + return await this.sendRequest( + peerId, + Method.LightClientFinalityUpdate, + [Version.V1], + null + ); + } + + async lightClientUpdate( + peerId: PeerId, + request: altair.LightClientUpdatesByRange + ): Promise { + return await this.sendRequest( + peerId, + Method.LightClientUpdate, + [Version.V1], + request, + request.count + ); + } + // Helper to reduce code duplication private async sendRequest( peerId: PeerId, @@ -257,7 +298,18 @@ export class ReqResp implements IReqResp { case Method.BeaconBlocksByRoot: yield* this.reqRespHandlers.onBeaconBlocksByRoot(requestTyped.body); break; - + case Method.LightClientBootstrap: + yield* this.reqRespHandlers.onLightClientBootstrap(requestTyped.body); + break; + case Method.LightClientOptimisticUpdate: + yield* this.reqRespHandlers.onLightClientOptimisticUpdate(); + break; + case Method.LightClientFinalityUpdate: + yield* this.reqRespHandlers.onLightClientFinalityUpdate(); + break; + case Method.LightClientUpdate: + yield* this.reqRespHandlers.onLightClientUpdatesByRange(requestTyped.body); + break; default: throw Error(`Unsupported method ${protocol.method}`); } diff --git a/packages/beacon-node/src/network/reqresp/types.ts b/packages/beacon-node/src/network/reqresp/types.ts index 952aa7856b4a..25c1356dd246 100644 --- a/packages/beacon-node/src/network/reqresp/types.ts +++ b/packages/beacon-node/src/network/reqresp/types.ts @@ -1,16 +1,21 @@ import {ForkName} from "@lodestar/params"; -import {allForks, phase0, ssz, Slot} from "@lodestar/types"; +import {allForks, phase0, ssz, Slot, altair, Root} from "@lodestar/types"; export const protocolPrefix = "/eth2/beacon_chain/req"; /** ReqResp protocol names or methods. Each Method can have multiple versions and encodings */ export enum Method { + // Phase 0 Status = "status", Goodbye = "goodbye", Ping = "ping", Metadata = "metadata", BeaconBlocksByRange = "beacon_blocks_by_range", BeaconBlocksByRoot = "beacon_blocks_by_root", + LightClientBootstrap = "light_client_bootstrap", + LightClientUpdate = "light_client_updates_by_range", + LightClientFinalityUpdate = "light_client_finality_update", + LightClientOptimisticUpdate = "light_client_optimistic_update", } /** RPC Versions */ @@ -43,6 +48,10 @@ export const protocolsSupported: [Method, Version, Encoding][] = [ [Method.BeaconBlocksByRange, Version.V2, Encoding.SSZ_SNAPPY], [Method.BeaconBlocksByRoot, Version.V1, Encoding.SSZ_SNAPPY], [Method.BeaconBlocksByRoot, Version.V2, Encoding.SSZ_SNAPPY], + [Method.LightClientBootstrap, Version.V1, Encoding.SSZ_SNAPPY], + [Method.LightClientUpdate, Version.V1, Encoding.SSZ_SNAPPY], + [Method.LightClientFinalityUpdate, Version.V1, Encoding.SSZ_SNAPPY], + [Method.LightClientOptimisticUpdate, Version.V1, Encoding.SSZ_SNAPPY], ]; export const isSingleResponseChunkByMethod: {[K in Method]: boolean} = { @@ -52,6 +61,10 @@ export const isSingleResponseChunkByMethod: {[K in Method]: boolean} = { [Method.Metadata]: true, [Method.BeaconBlocksByRange]: false, // A stream, 0 or more response chunks [Method.BeaconBlocksByRoot]: false, + [Method.LightClientBootstrap]: true, + [Method.LightClientUpdate]: false, + [Method.LightClientFinalityUpdate]: true, + [Method.LightClientOptimisticUpdate]: true, }; export const CONTEXT_BYTES_FORK_DIGEST_LENGTH = 4; @@ -70,6 +83,11 @@ export function contextBytesTypeByProtocol(protocol: Protocol): ContextBytesType case Method.Ping: case Method.Metadata: return ContextBytesType.Empty; + case Method.LightClientBootstrap: + case Method.LightClientUpdate: + case Method.LightClientFinalityUpdate: + case Method.LightClientOptimisticUpdate: + return ContextBytesType.ForkDigest; case Method.BeaconBlocksByRange: case Method.BeaconBlocksByRoot: switch (protocol.version) { @@ -92,11 +110,17 @@ export function getRequestSzzTypeByMethod(method: Method) { case Method.Ping: return ssz.phase0.Ping; case Method.Metadata: + case Method.LightClientFinalityUpdate: + case Method.LightClientOptimisticUpdate: return null; case Method.BeaconBlocksByRange: return ssz.phase0.BeaconBlocksByRangeRequest; case Method.BeaconBlocksByRoot: return ssz.phase0.BeaconBlocksByRootRequest; + case Method.LightClientBootstrap: + return ssz.Root; + case Method.LightClientUpdate: + return ssz.altair.LightClientUpdatesByRange; } } @@ -107,6 +131,10 @@ export type RequestBodyByMethod = { [Method.Metadata]: null; [Method.BeaconBlocksByRange]: phase0.BeaconBlocksByRangeRequest; [Method.BeaconBlocksByRoot]: phase0.BeaconBlocksByRootRequest; + [Method.LightClientBootstrap]: Root; + [Method.LightClientUpdate]: altair.LightClientUpdatesByRange; + [Method.LightClientFinalityUpdate]: null; + [Method.LightClientOptimisticUpdate]: null; }; /** Response SSZ type for each method and ForkName */ @@ -128,6 +156,14 @@ export function getResponseSzzTypeByMethod(protocol: Protocol, forkName: ForkNam case Method.BeaconBlocksByRoot: // SignedBeaconBlock type is changed in altair return ssz[forkName].SignedBeaconBlock; + case Method.LightClientBootstrap: + return ssz.altair.LightClientBootstrap; + case Method.LightClientUpdate: + return ssz.altair.LightClientUpdate; + case Method.LightClientFinalityUpdate: + return ssz.altair.LightClientFinalityUpdate; + case Method.LightClientOptimisticUpdate: + return ssz.altair.LightClientOptimisticUpdate; } } @@ -148,6 +184,14 @@ export function getOutgoingSerializerByMethod(protocol: Protocol): OutgoingSeria case Method.BeaconBlocksByRange: case Method.BeaconBlocksByRoot: return reqRespBlockResponseSerializer; + case Method.LightClientBootstrap: + return ssz.altair.LightClientBootstrap; + case Method.LightClientUpdate: + return ssz.altair.LightClientUpdate; + case Method.LightClientFinalityUpdate: + return ssz.altair.LightClientFinalityUpdate; + case Method.LightClientOptimisticUpdate: + return ssz.altair.LightClientOptimisticUpdate; } } @@ -156,6 +200,10 @@ type CommonResponseBodyByMethod = { [Method.Goodbye]: phase0.Goodbye; [Method.Ping]: phase0.Ping; [Method.Metadata]: phase0.Metadata; + [Method.LightClientBootstrap]: altair.LightClientBootstrap; + [Method.LightClientUpdate]: altair.LightClientUpdate; + [Method.LightClientFinalityUpdate]: altair.LightClientFinalityUpdate; + [Method.LightClientOptimisticUpdate]: altair.LightClientOptimisticUpdate; }; // Used internally by lodestar to response to beacon_blocks_by_range and beacon_blocks_by_root diff --git a/packages/beacon-node/src/network/reqresp/utils/renderRequestBody.ts b/packages/beacon-node/src/network/reqresp/utils/renderRequestBody.ts index a9e476294f08..823be579f55f 100644 --- a/packages/beacon-node/src/network/reqresp/utils/renderRequestBody.ts +++ b/packages/beacon-node/src/network/reqresp/utils/renderRequestBody.ts @@ -1,5 +1,5 @@ import {toHexString} from "@lodestar/utils"; -import {Method, RequestBodyByMethod, RequestBody} from "../types.js"; +import {Method, RequestBody, RequestBodyByMethod} from "../types.js"; /** * Render requestBody as a succint string for debug purposes @@ -17,6 +17,8 @@ export function renderRequestBody(method: Method, requestBody: RequestBody): str return (requestBody as RequestBodyByMethod[Method.Ping]).toString(10); case Method.Metadata: + case Method.LightClientFinalityUpdate: + case Method.LightClientOptimisticUpdate: return "null"; case Method.BeaconBlocksByRange: { @@ -28,5 +30,13 @@ export function renderRequestBody(method: Method, requestBody: RequestBody): str return ((requestBody as RequestBodyByMethod[Method.BeaconBlocksByRoot]) as Uint8Array[]) .map((root) => toHexString(root)) .join(","); + + case Method.LightClientBootstrap: + return toHexString((requestBody as RequestBodyByMethod[Method.LightClientBootstrap]) as Uint8Array); + + case Method.LightClientUpdate: { + const updateRequest = requestBody as RequestBodyByMethod[Method.LightClientUpdate]; + return `${updateRequest.startPeriod},${updateRequest.count}`; + } } } diff --git a/packages/beacon-node/src/network/util.ts b/packages/beacon-node/src/network/util.ts index 1a39a91b154c..6ab9ce890b42 100644 --- a/packages/beacon-node/src/network/util.ts +++ b/packages/beacon-node/src/network/util.ts @@ -76,3 +76,8 @@ export function getConnectionsMap(connectionManager: ConnectionManager): Map { + return { + ...zeroProtoBlock, + slot: ALTAIR_START_SLOT, + }; + }; + const db = new StubbedBeaconDb(config); const reqRespHandlers = getReqRespHandlers({db, chain}); const gossipHandlers = gossipHandlersPartial as GossipHandlers; @@ -165,4 +182,76 @@ describe("gossipsub", function () { } } }); + + it("Publish and receive a LightClientOptimisticUpdate", async function () { + let onLightClientOptimisticUpdate: (ou: altair.LightClientOptimisticUpdate) => void; + const onLightClientOptimisticUpdatePromise = new Promise( + (resolve) => (onLightClientOptimisticUpdate = resolve) + ); + + const {netA, netB, controller} = await mockModules({ + [GossipType.light_client_optimistic_update]: async (lightClientOptimisticUpdate) => { + onLightClientOptimisticUpdate(lightClientOptimisticUpdate); + }, + }); + + await Promise.all([onPeerConnect(netA), onPeerConnect(netB), connect(netA, netB.peerId, netB.localMultiaddrs)]); + expect(Array.from(netA.getConnectionsByPeer().values()).length).to.equal(1); + expect(Array.from(netB.getConnectionsByPeer().values()).length).to.equal(1); + + netA.subscribeGossipCoreTopics(); + netB.subscribeGossipCoreTopics(); + + // Wait to have a peer connected to a topic + while (!controller.signal.aborted) { + await sleep(500); + const topicStr = netA.gossip.getTopics()[0]; + if (topicStr && netA.gossip.getMeshPeers(topicStr).length > 0) { + break; + } + } + + const lightClientOptimisticUpdate = ssz.altair.LightClientOptimisticUpdate.defaultValue(); + lightClientOptimisticUpdate.signatureSlot = ALTAIR_START_SLOT; + await netA.gossip.publishLightClientOptimisticUpdate(lightClientOptimisticUpdate); + + const optimisticUpdate = await onLightClientOptimisticUpdatePromise; + expect(optimisticUpdate).to.deep.equal(lightClientOptimisticUpdate); + }); + + it("Publish and receive a LightClientFinalityUpdate", async function () { + let onLightClientFinalityUpdate: (fu: altair.LightClientFinalityUpdate) => void; + const onLightClientFinalityUpdatePromise = new Promise( + (resolve) => (onLightClientFinalityUpdate = resolve) + ); + + const {netA, netB, controller} = await mockModules({ + [GossipType.light_client_finality_update]: async (lightClientFinalityUpdate) => { + onLightClientFinalityUpdate(lightClientFinalityUpdate); + }, + }); + + await Promise.all([onPeerConnect(netA), onPeerConnect(netB), connect(netA, netB.peerId, netB.localMultiaddrs)]); + expect(Array.from(netA.getConnectionsByPeer().values()).length).to.equal(1); + expect(Array.from(netB.getConnectionsByPeer().values()).length).to.equal(1); + + netA.subscribeGossipCoreTopics(); + netB.subscribeGossipCoreTopics(); + + // Wait to have a peer connected to a topic + while (!controller.signal.aborted) { + await sleep(500); + const topicStr = netA.gossip.getTopics()[0]; + if (topicStr && netA.gossip.getMeshPeers(topicStr).length > 0) { + break; + } + } + + const lightClientFinalityUpdate = ssz.altair.LightClientFinalityUpdate.defaultValue(); + lightClientFinalityUpdate.signatureSlot = ALTAIR_START_SLOT; + await netA.gossip.publishLightClientFinalityUpdate(lightClientFinalityUpdate); + + const optimisticUpdate = await onLightClientFinalityUpdatePromise; + expect(optimisticUpdate).to.deep.equal(lightClientFinalityUpdate); + }); }); diff --git a/packages/beacon-node/test/e2e/network/network.test.ts b/packages/beacon-node/test/e2e/network/network.test.ts index d2102a2be6a6..41029af759c6 100644 --- a/packages/beacon-node/test/e2e/network/network.test.ts +++ b/packages/beacon-node/test/e2e/network/network.test.ts @@ -9,12 +9,13 @@ import {config} from "@lodestar/config/default"; import {phase0, ssz} from "@lodestar/types"; import {sleep} from "@lodestar/utils"; +import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {Network, NetworkEvent, ReqRespMethod, getReqRespHandlers} from "../../../src/network/index.js"; import {defaultNetworkOptions, INetworkOptions} from "../../../src/network/options.js"; import {GoodByeReasonCode} from "../../../src/constants/index.js"; import {generateEmptySignedBlock} from "../../utils/block.js"; -import {MockBeaconChain} from "../../utils/mocks/chain/chain.js"; +import {MockBeaconChain, zeroProtoBlock} from "../../utils/mocks/chain/chain.js"; import {createNode} from "../../utils/network.js"; import {generateState} from "../../utils/state.js"; import {StubbedBeaconDb} from "../../utils/stub/index.js"; @@ -81,6 +82,14 @@ describe("network", function () { async function createTestNode(nodeName: string) { const {state, config} = getStaticData(); const chain = new MockBeaconChain({genesisTime: 0, chainId: 0, networkId: BigInt(0), state, config}); + + chain.forkChoice.getHead = () => { + return { + ...zeroProtoBlock, + slot: computeStartSlotAtEpoch(config.ALTAIR_FORK_EPOCH), + }; + }; + const db = new StubbedBeaconDb(config); const reqRespHandlers = getReqRespHandlers({db, chain}); const gossipHandlers = {} as GossipHandlers; @@ -222,7 +231,7 @@ describe("network", function () { expect(netA.gossip.getTopics().length).to.equal(0); netA.subscribeGossipCoreTopics(); - expect(netA.gossip.getTopics().length).to.equal(5); + expect(netA.gossip.getTopics().length).to.equal(13); netA.unsubscribeGossipCoreTopics(); expect(netA.gossip.getTopics().length).to.equal(0); netA.close(); diff --git a/packages/beacon-node/test/e2e/network/peers/peerManager.test.ts b/packages/beacon-node/test/e2e/network/peers/peerManager.test.ts index c8142a85b58d..b634aee2102e 100644 --- a/packages/beacon-node/test/e2e/network/peers/peerManager.test.ts +++ b/packages/beacon-node/test/e2e/network/peers/peerManager.test.ts @@ -111,6 +111,10 @@ describe("network / peers / PeerManager", function () { beaconBlocksByRange = sinon.stub(); beaconBlocksByRoot = sinon.stub(); pruneOnPeerDisconnect = sinon.stub(); + lightClientBootstrap = sinon.stub(); + lightClientOptimisticUpdate = sinon.stub(); + lightClientFinalityUpdate = sinon.stub(); + lightClientUpdate = sinon.stub(); } it("Should request metadata on receivedPing of unknown peer", async () => { diff --git a/packages/beacon-node/test/e2e/network/reqresp.test.ts b/packages/beacon-node/test/e2e/network/reqresp.test.ts index c9bb6733a8e8..efd27148f77d 100644 --- a/packages/beacon-node/test/e2e/network/reqresp.test.ts +++ b/packages/beacon-node/test/e2e/network/reqresp.test.ts @@ -4,7 +4,7 @@ import {createSecp256k1PeerId} from "@libp2p/peer-id-factory"; import {createIBeaconConfig} from "@lodestar/config"; import {config} from "@lodestar/config/default"; import {sleep as _sleep} from "@lodestar/utils"; -import {altair, phase0, ssz} from "@lodestar/types"; +import {altair, phase0, Root, ssz} from "@lodestar/types"; import {ForkName} from "@lodestar/params"; import {BitArray} from "@chainsafe/ssz"; import {IReqRespOptions, Network} from "../../../src/network/index.js"; @@ -81,6 +81,10 @@ describe("network / ReqResp", function () { onStatus: notImplemented, onBeaconBlocksByRange: notImplemented, onBeaconBlocksByRoot: notImplemented, + onLightClientBootstrap: notImplemented, + onLightClientUpdatesByRange: notImplemented, + onLightClientOptimisticUpdate: notImplemented, + onLightClientFinalityUpdate: notImplemented, ...reqRespHandlersPartial, }; @@ -189,13 +193,81 @@ describe("network / ReqResp", function () { const returnedBlocks = await netA.reqResp.beaconBlocksByRange(netB.peerId, req); if (returnedBlocks === null) throw Error("Returned null"); - expect(returnedBlocks).to.have.length(req.count, "Wrong returnedBlocks lenght"); + expect(returnedBlocks).to.have.length(req.count, "Wrong returnedBlocks length"); for (const [i, returnedBlock] of returnedBlocks.entries()) { expect(ssz.phase0.SignedBeaconBlock.equals(returnedBlock, blocks[i])).to.equal(true, `Wrong returnedBlock[${i}]`); } }); + it("should send/receive a light client bootstrap message", async function () { + const root: Root = ssz.phase0.BeaconBlockHeader.defaultValue().bodyRoot; + const expectedValue = ssz.altair.LightClientBootstrap.defaultValue(); + + const [netA, netB] = await createAndConnectPeers({ + onLightClientBootstrap: async function* onRequest() { + yield expectedValue; + }, + }); + + const returnedValue = await netA.reqResp.lightClientBootstrap(netB.peerId, root); + expect(returnedValue).to.deep.equal(expectedValue, "Wrong response body"); + }); + + it("should send/receive a light client optimistic update message", async function () { + const expectedValue = ssz.altair.LightClientOptimisticUpdate.defaultValue(); + + const [netA, netB] = await createAndConnectPeers({ + onLightClientOptimisticUpdate: async function* onRequest() { + yield expectedValue; + }, + }); + + const returnedValue = await netA.reqResp.lightClientOptimisticUpdate(netB.peerId); + expect(returnedValue).to.deep.equal(expectedValue, "Wrong response body"); + }); + + it("should send/receive a light client finality update message", async function () { + const expectedValue = ssz.altair.LightClientFinalityUpdate.defaultValue(); + + const [netA, netB] = await createAndConnectPeers({ + onLightClientFinalityUpdate: async function* onRequest() { + yield expectedValue; + }, + }); + + const returnedValue = await netA.reqResp.lightClientFinalityUpdate(netB.peerId); + expect(returnedValue).to.deep.equal(expectedValue, "Wrong response body"); + }); + + it("should send/receive a light client update message", async function () { + const req: altair.LightClientUpdatesByRange = {startPeriod: 0, count: 2}; + const lightClientUpdates: altair.LightClientUpdate[] = []; + for (let slot = req.startPeriod; slot < req.count; slot++) { + const update = ssz.altair.LightClientUpdate.defaultValue(); + update.signatureSlot = slot; + lightClientUpdates.push(update); + } + + const [netA, netB] = await createAndConnectPeers({ + onLightClientUpdatesByRange: async function* () { + yield* arrToSource(lightClientUpdates); + }, + }); + + const returnedUpdates = await netA.reqResp.lightClientUpdate(netB.peerId, req); + + if (returnedUpdates === null) throw Error("Returned null"); + expect(returnedUpdates).to.have.length(2, "Wrong returnedUpdates length"); + + for (const [i, returnedUpdate] of returnedUpdates.entries()) { + expect(ssz.altair.LightClientUpdate.equals(returnedUpdate, lightClientUpdates[i])).to.equal( + true, + `Wrong returnedUpdate[${i}]` + ); + } + }); + it("should handle a server error", async function () { const testErrorMessage = "TEST_EXAMPLE_ERROR_1234"; const [netA, netB] = await createAndConnectPeers({ diff --git a/packages/beacon-node/test/unit/chain/validation/lightClientFinalityUpdate.test.ts b/packages/beacon-node/test/unit/chain/validation/lightClientFinalityUpdate.test.ts new file mode 100644 index 000000000000..1011d02609b4 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/validation/lightClientFinalityUpdate.test.ts @@ -0,0 +1,179 @@ +import {expect} from "chai"; +import sinon from "sinon"; +import {createIBeaconConfig} from "@lodestar/config"; +import {config} from "@lodestar/config/default"; +import {altair, ssz} from "@lodestar/types"; + +import {computeTimeAtSlot} from "@lodestar/state-transition"; +import {generateEmptySignedBlock} from "../../../utils/block.js"; +import {MockBeaconChain} from "../../../utils/mocks/chain/chain.js"; +import {generateState} from "../../../utils/state.js"; +import {validateLightClientFinalityUpdate} from "../../../../src/chain/validation/lightClientFinalityUpdate.js"; +import {LightClientErrorCode} from "../../../../src/chain/errors/lightClientError.js"; +import {IBeaconChain} from "../../../../src/chain/index.js"; + +describe("Light Client Finality Update validation", function () { + let fakeClock: sinon.SinonFakeTimers; + const afterEachCallbacks: (() => Promise | void)[] = []; + beforeEach(() => { + fakeClock = sinon.useFakeTimers(); + }); + afterEach(async () => { + fakeClock.restore(); + while (afterEachCallbacks.length > 0) { + const callback = afterEachCallbacks.pop(); + if (callback) await callback(); + } + }); + + function mockChain(): IBeaconChain { + const block = generateEmptySignedBlock(); + const state = generateState({ + finalizedCheckpoint: { + epoch: 0, + root: ssz.phase0.BeaconBlock.hashTreeRoot(block.message), + }, + }); + + const beaconConfig = createIBeaconConfig(config, state.genesisValidatorsRoot); + const chain = new MockBeaconChain({ + genesisTime: 0, + chainId: 0, + networkId: BigInt(0), + state, + config: beaconConfig, + }); + + afterEachCallbacks.push(async () => { + await chain.close(); + }); + + return chain; + } + + it("should return invalid - finality update already forwarded", async () => { + const lightclientFinalityUpdate: altair.LightClientFinalityUpdate = ssz.altair.LightClientFinalityUpdate.defaultValue(); + lightclientFinalityUpdate.finalizedHeader.slot = 2; + + const chain = mockChain(); + chain.lightClientServer.getFinalityUpdate = () => { + const defaultValue = ssz.altair.LightClientFinalityUpdate.defaultValue(); + // make the local slot higher than gossiped + defaultValue.finalizedHeader.slot = lightclientFinalityUpdate.finalizedHeader.slot + 1; + return defaultValue; + }; + + expect(() => { + validateLightClientFinalityUpdate(config, chain, lightclientFinalityUpdate); + }).to.throw( + LightClientErrorCode.FINALITY_UPDATE_ALREADY_FORWARDED, + "Expected LightClientErrorCode.FINALITY_UPDATE_ALREADY_FORWARDED to be thrown" + ); + }); + + it("should return invalid - finality update received too early", async () => { + //No other optimistic_update with a lower or equal attested_header.slot was already forwarded on the network + const lightClientFinalityUpdate: altair.LightClientFinalityUpdate = ssz.altair.LightClientFinalityUpdate.defaultValue(); + lightClientFinalityUpdate.finalizedHeader.slot = 2; + lightClientFinalityUpdate.signatureSlot = 4; + + const chain = mockChain(); + chain.lightClientServer.getFinalityUpdate = () => { + const defaultValue = ssz.altair.LightClientFinalityUpdate.defaultValue(); + defaultValue.finalizedHeader.slot = 1; + return defaultValue; + }; + + expect(() => { + validateLightClientFinalityUpdate(config, chain, lightClientFinalityUpdate); + }).to.throw( + LightClientErrorCode.FINALITY_UPDATE_RECEIVED_TOO_EARLY, + "Expected LightClientErrorCode.FINALITY_UPDATE_RECEIVED_TOO_EARLY to be thrown" + ); + }); + + it("should return invalid - finality update not matching local", async () => { + const lightClientFinalityUpdate: altair.LightClientFinalityUpdate = ssz.altair.LightClientFinalityUpdate.defaultValue(); + lightClientFinalityUpdate.finalizedHeader.slot = 2; + lightClientFinalityUpdate.signatureSlot = lightClientFinalityUpdate.finalizedHeader.slot + 1; + + const chain = mockChain(); + + // make lightclientserver return another update with different value from gossiped + chain.lightClientServer.getFinalityUpdate = () => { + const defaultValue = ssz.altair.LightClientFinalityUpdate.defaultValue(); + defaultValue.finalizedHeader.slot = 1; + return defaultValue; + }; + + // make update not too early + const timeAtSignatureSlot = + computeTimeAtSlot(config, lightClientFinalityUpdate.signatureSlot, chain.genesisTime) * 1000; + fakeClock.tick(timeAtSignatureSlot + (1 / 3) * (config.SECONDS_PER_SLOT + 1) * 1000); + + expect(() => { + validateLightClientFinalityUpdate(config, chain, lightClientFinalityUpdate); + }).to.throw( + LightClientErrorCode.FINALITY_UPDATE_NOT_MATCHING_LOCAL, + "Expected LightClientErrorCode.FINALITY_UPDATE_NOT_MATCHING_LOCAL to be thrown" + ); + }); + + it("should return invalid - not matching local when no local finality update yet", async () => { + const lightClientFinalityUpdate: altair.LightClientFinalityUpdate = ssz.altair.LightClientFinalityUpdate.defaultValue(); + lightClientFinalityUpdate.finalizedHeader.slot = 2; + lightClientFinalityUpdate.signatureSlot = lightClientFinalityUpdate.finalizedHeader.slot + 1; + + const chain = mockChain(); + + // make update not too early + const timeAtSignatureSlot = + computeTimeAtSlot(config, lightClientFinalityUpdate.signatureSlot, chain.genesisTime) * 1000; + fakeClock.tick(timeAtSignatureSlot + (1 / 3) * (config.SECONDS_PER_SLOT + 1) * 1000); + + // chain's getFinalityUpdate not mocked. + // localFinalityUpdate will be null + // latestForwardedFinalitySlot will be -1 + + expect(() => { + validateLightClientFinalityUpdate(config, chain, lightClientFinalityUpdate); + }).to.throw( + LightClientErrorCode.FINALITY_UPDATE_NOT_MATCHING_LOCAL, + "Expected LightClientErrorCode.FINALITY_UPDATE_NOT_MATCHING_LOCAL to be thrown" + ); + }); + + it("should not throw for valid update", async () => { + const lightClientFinalityUpdate: altair.LightClientFinalityUpdate = ssz.altair.LightClientFinalityUpdate.defaultValue(); + const chain = mockChain(); + + // satisfy: + // No other finality_update with a lower or equal finalized_header.slot was already forwarded on the network + lightClientFinalityUpdate.finalizedHeader.slot = 2; + + chain.lightClientServer.getFinalityUpdate = () => { + const defaultValue = ssz.altair.LightClientFinalityUpdate.defaultValue(); + defaultValue.finalizedHeader.slot = 1; + return defaultValue; + }; + + // satisfy: + // [IGNORE] The finality_update is received after the block at signature_slot was given enough time to propagate + // through the network -- i.e. validate that one-third of finality_update.signature_slot has transpired + // (SECONDS_PER_SLOT / INTERVALS_PER_SLOT seconds after the start of the slot, with a MAXIMUM_GOSSIP_CLOCK_DISPARITY allowance) + // const currentTime = computeTimeAtSlot(config, chain.clock.currentSlotWithGossipDisparity, chain.genesisTime); + const timeAtSignatureSlot = + computeTimeAtSlot(config, lightClientFinalityUpdate.signatureSlot, chain.genesisTime) * 1000; + fakeClock.tick(timeAtSignatureSlot + (1 / 3) * (config.SECONDS_PER_SLOT + 1) * 1000); + + // satisfy: + // [IGNORE] The received finality_update matches the locally computed one exactly + chain.lightClientServer.getFinalityUpdate = () => { + return lightClientFinalityUpdate; + }; + + expect(() => { + validateLightClientFinalityUpdate(config, chain, lightClientFinalityUpdate); + }).to.not.throw("Expected validateLightClientFinalityUpdate not to throw"); + }); +}); diff --git a/packages/beacon-node/test/unit/chain/validation/lightClientOptimisticUpdate.test.ts b/packages/beacon-node/test/unit/chain/validation/lightClientOptimisticUpdate.test.ts new file mode 100644 index 000000000000..310527486de8 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/validation/lightClientOptimisticUpdate.test.ts @@ -0,0 +1,173 @@ +import {expect} from "chai"; +import sinon from "sinon"; +import {createIBeaconConfig} from "@lodestar/config"; +import {config} from "@lodestar/config/default"; +import {altair, ssz} from "@lodestar/types"; + +import {computeTimeAtSlot} from "@lodestar/state-transition"; +import {generateEmptySignedBlock} from "../../../utils/block.js"; +import {MockBeaconChain} from "../../../utils/mocks/chain/chain.js"; +import {generateState} from "../../../utils/state.js"; +import {validateLightClientOptimisticUpdate} from "../../../../src/chain/validation/lightClientOptimisticUpdate.js"; +import {LightClientErrorCode} from "../../../../src/chain/errors/lightClientError.js"; +import {IBeaconChain} from "../../../../src/chain/index.js"; + +describe("Light Client Optimistic Update validation", function () { + let fakeClock: sinon.SinonFakeTimers; + const afterEachCallbacks: (() => Promise | void)[] = []; + beforeEach(() => { + fakeClock = sinon.useFakeTimers(); + }); + afterEach(async () => { + fakeClock.restore(); + while (afterEachCallbacks.length > 0) { + const callback = afterEachCallbacks.pop(); + if (callback) await callback(); + } + }); + + function mockChain(): IBeaconChain { + const block = generateEmptySignedBlock(); + const state = generateState({ + finalizedCheckpoint: { + epoch: 0, + root: ssz.phase0.BeaconBlock.hashTreeRoot(block.message), + }, + }); + + const beaconConfig = createIBeaconConfig(config, state.genesisValidatorsRoot); + const chain = new MockBeaconChain({ + genesisTime: 0, + chainId: 0, + networkId: BigInt(0), + state, + config: beaconConfig, + }); + + afterEachCallbacks.push(async () => { + await chain.close(); + }); + + return chain; + } + + it("should return invalid - optimistic update already forwarded", async () => { + const lightclientOptimisticUpdate: altair.LightClientOptimisticUpdate = ssz.altair.LightClientOptimisticUpdate.defaultValue(); + + lightclientOptimisticUpdate.attestedHeader.slot = 2; + + const chain = mockChain(); + chain.lightClientServer.getOptimisticUpdate = () => { + const defaultValue = ssz.altair.LightClientOptimisticUpdate.defaultValue(); + // make the local slot higher than gossiped + defaultValue.attestedHeader.slot = lightclientOptimisticUpdate.attestedHeader.slot + 1; + return defaultValue; + }; + + expect(() => { + validateLightClientOptimisticUpdate(config, chain, lightclientOptimisticUpdate); + }).to.throw( + LightClientErrorCode.OPTIMISTIC_UPDATE_ALREADY_FORWARDED, + "Expected LightClientErrorCode.OPTIMISTIC_UPDATE_ALREADY_FORWARDED to be thrown" + ); + }); + + it("should return invalid - optimistic update received too early", async () => { + const lightclientOptimisticUpdate: altair.LightClientOptimisticUpdate = ssz.altair.LightClientOptimisticUpdate.defaultValue(); + lightclientOptimisticUpdate.attestedHeader.slot = 2; + lightclientOptimisticUpdate.signatureSlot = 4; + + const chain = mockChain(); + chain.lightClientServer.getOptimisticUpdate = () => { + const defaultValue = ssz.altair.LightClientOptimisticUpdate.defaultValue(); + defaultValue.attestedHeader.slot = 1; + return defaultValue; + }; + + expect(() => { + validateLightClientOptimisticUpdate(config, chain, lightclientOptimisticUpdate); + }).to.throw( + LightClientErrorCode.OPTIMISTIC_UPDATE_RECEIVED_TOO_EARLY, + "Expected LightClientErrorCode.OPTIMISTIC_UPDATE_RECEIVED_TOO_EARLY to be thrown" + ); + }); + + it("should return invalid - optimistic update not matching local", async () => { + const lightclientOptimisticUpdate: altair.LightClientOptimisticUpdate = ssz.altair.LightClientOptimisticUpdate.defaultValue(); + lightclientOptimisticUpdate.attestedHeader.slot = 2; + + const chain = mockChain(); + + const timeAtSignatureSlot = + computeTimeAtSlot(config, lightclientOptimisticUpdate.signatureSlot, chain.genesisTime) * 1000; + fakeClock.tick(timeAtSignatureSlot + (1 / 3) * (config.SECONDS_PER_SLOT + 1) * 1000); + + // make lightclientserver return another update with different value from gossiped + chain.lightClientServer.getOptimisticUpdate = () => { + const defaultValue = ssz.altair.LightClientOptimisticUpdate.defaultValue(); + defaultValue.attestedHeader.slot = 1; + return defaultValue; + }; + + expect(() => { + validateLightClientOptimisticUpdate(config, chain, lightclientOptimisticUpdate); + }).to.throw( + LightClientErrorCode.OPTIMISTIC_UPDATE_NOT_MATCHING_LOCAL, + "Expected LightClientErrorCode.OPTIMISTIC_UPDATE_NOT_MATCHING_LOCAL to be thrown" + ); + }); + + it("should return invalid - not matching local when no local optimistic update yet", async () => { + const lightclientOptimisticUpdate: altair.LightClientOptimisticUpdate = ssz.altair.LightClientOptimisticUpdate.defaultValue(); + lightclientOptimisticUpdate.attestedHeader.slot = 2; + + const chain = mockChain(); + + const timeAtSignatureSlot = + computeTimeAtSlot(config, lightclientOptimisticUpdate.signatureSlot, chain.genesisTime) * 1000; + fakeClock.tick(timeAtSignatureSlot + (1 / 3) * (config.SECONDS_PER_SLOT + 1) * 1000); + + // chain getOptimisticUpdate not mocked. + // localOptimisticUpdate will be null + // latestForwardedOptimisticSlot will be -1 + expect(() => { + validateLightClientOptimisticUpdate(config, chain, lightclientOptimisticUpdate); + }).to.throw( + LightClientErrorCode.OPTIMISTIC_UPDATE_NOT_MATCHING_LOCAL, + "Expected LightClientErrorCode.OPTIMISTIC_UPDATE_NOT_MATCHING_LOCAL to be thrown" + ); + }); + + it("should not throw for valid update", async () => { + const lightclientOptimisticUpdate: altair.LightClientOptimisticUpdate = ssz.altair.LightClientOptimisticUpdate.defaultValue(); + const chain = mockChain(); + + // satisfy: + // No other optimistic_update with a lower or equal attested_header.slot was already forwarded on the network + lightclientOptimisticUpdate.attestedHeader.slot = 2; + + chain.lightClientServer.getOptimisticUpdate = () => { + const defaultValue = ssz.altair.LightClientOptimisticUpdate.defaultValue(); + defaultValue.attestedHeader.slot = 1; + return defaultValue; + }; + + // satisfy: + // [IGNORE] The optimistic_update is received after the block at signature_slot was given enough time to propagate + // through the network -- i.e. validate that one-third of optimistic_update.signature_slot has transpired + // (SECONDS_PER_SLOT / INTERVALS_PER_SLOT seconds after the start of the slot, with a MAXIMUM_GOSSIP_CLOCK_DISPARITY allowance) + const timeAtSignatureSlot = + computeTimeAtSlot(config, lightclientOptimisticUpdate.signatureSlot, chain.genesisTime) * 1000; + fakeClock.tick(timeAtSignatureSlot + (1 / 3) * (config.SECONDS_PER_SLOT + 1) * 1000); + + // satisfy: + // [IGNORE] The received optimistic_update matches the locally computed one exactly + chain.lightClientServer.getOptimisticUpdate = () => { + return lightclientOptimisticUpdate; + }; + + expect(() => { + validateLightClientOptimisticUpdate(config, chain, lightclientOptimisticUpdate); + }).to.not.throw("Expected validateLightclientOptimisticUpdate not to throw"); + }); +}); diff --git a/packages/beacon-node/test/unit/network/gossip/topic.test.ts b/packages/beacon-node/test/unit/network/gossip/topic.test.ts index fe6723bf3077..f5e7f627baf1 100644 --- a/packages/beacon-node/test/unit/network/gossip/topic.test.ts +++ b/packages/beacon-node/test/unit/network/gossip/topic.test.ts @@ -57,6 +57,18 @@ describe("network / gossip / topic", function () { topicStr: "/eth2/8e04f66f/sync_committee_5/ssz_snappy", }, ], + [GossipType.light_client_finality_update]: [ + { + topic: {type: GossipType.light_client_finality_update, fork: ForkName.altair, encoding}, + topicStr: "/eth2/8e04f66f/light_client_finality_update/ssz_snappy", + }, + ], + [GossipType.light_client_optimistic_update]: [ + { + topic: {type: GossipType.light_client_optimistic_update, fork: ForkName.altair, encoding}, + topicStr: "/eth2/8e04f66f/light_client_optimistic_update/ssz_snappy", + }, + ], }; for (const topics of Object.values(testCases)) { diff --git a/packages/beacon-node/test/unit/network/reqresp/encoders/responseTypes.test.ts b/packages/beacon-node/test/unit/network/reqresp/encoders/responseTypes.test.ts index b6100ffb313f..098a8572f5f0 100644 --- a/packages/beacon-node/test/unit/network/reqresp/encoders/responseTypes.test.ts +++ b/packages/beacon-node/test/unit/network/reqresp/encoders/responseTypes.test.ts @@ -1,6 +1,6 @@ import {pipe} from "it-pipe"; import all from "it-all"; -import {allForks} from "@lodestar/types"; +import {allForks, ssz} from "@lodestar/types"; import {ForkName} from "@lodestar/params"; import { Method, @@ -26,6 +26,10 @@ describe("network / reqresp / encoders / responseTypes", () => { [Method.Metadata]: [], [Method.BeaconBlocksByRange]: [generateEmptySignedBlocks(2)], [Method.BeaconBlocksByRoot]: [generateEmptySignedBlocks(2)], + [Method.LightClientBootstrap]: [[ssz.altair.LightClientBootstrap.defaultValue()]], + [Method.LightClientUpdate]: [[ssz.altair.LightClientUpdate.defaultValue()]], + [Method.LightClientFinalityUpdate]: [[ssz.altair.LightClientFinalityUpdate.defaultValue()]], + [Method.LightClientOptimisticUpdate]: [[ssz.altair.LightClientOptimisticUpdate.defaultValue()]], }; const encodings: Encoding[] = [Encoding.SSZ_SNAPPY]; diff --git a/packages/light-client/src/index.ts b/packages/light-client/src/index.ts index 02ee1afef210..36cad5adb390 100644 --- a/packages/light-client/src/index.ts +++ b/packages/light-client/src/index.ts @@ -327,12 +327,7 @@ export class Lightclient { // Subscribe to head updates over SSE // TODO: Use polling for getLatestHeadUpdate() is SSE is unavailable this.api.events.eventstream( - [routes.events.EventType.lightclientOptimisticUpdate], - controller.signal, - this.onSSE - ); - this.api.events.eventstream( - [routes.events.EventType.lightclientFinalizedUpdate], + [routes.events.EventType.lightClientOptimisticUpdate, routes.events.EventType.lightClientFinalityUpdate], controller.signal, this.onSSE ); @@ -372,14 +367,18 @@ export class Lightclient { private onSSE = (event: routes.events.BeaconEvent): void => { try { switch (event.type) { - case routes.events.EventType.lightclientOptimisticUpdate: + case routes.events.EventType.lightClientOptimisticUpdate: this.processOptimisticUpdate(event.message); break; - case routes.events.EventType.lightclientFinalizedUpdate: + case routes.events.EventType.lightClientFinalityUpdate: this.processFinalizedUpdate(event.message); break; + case routes.events.EventType.lightClientUpdate: + this.processSyncCommitteeUpdate(event.message); + break; + default: throw Error(`Unknown event ${event.type}`); } @@ -392,7 +391,7 @@ export class Lightclient { * Processes new optimistic header updates in only known synced sync periods. * This headerUpdate may update the head if there's enough participation. */ - private processOptimisticUpdate(headerUpdate: routes.events.LightclientOptimisticHeaderUpdate): void { + private processOptimisticUpdate(headerUpdate: altair.LightClientOptimisticUpdate): void { const {attestedHeader, syncAggregate} = headerUpdate; // Prevent registering updates for slots to far ahead @@ -470,7 +469,7 @@ export class Lightclient { * Processes new header updates in only known synced sync periods. * This headerUpdate may update the head if there's enough participation. */ - private processFinalizedUpdate(finalizedUpdate: routes.events.LightclientFinalizedUpdate): void { + private processFinalizedUpdate(finalizedUpdate: altair.LightClientFinalityUpdate): void { // Validate sync aggregate of the attested header and other conditions like future update, period etc // and may be move head this.processOptimisticUpdate(finalizedUpdate); diff --git a/packages/light-client/src/validation.ts b/packages/light-client/src/validation.ts index a3b39ac7a1ae..b1e16443a7d4 100644 --- a/packages/light-client/src/validation.ts +++ b/packages/light-client/src/validation.ts @@ -10,7 +10,6 @@ import { DOMAIN_SYNC_COMMITTEE, } from "@lodestar/params"; import {IBeaconConfig} from "@lodestar/config"; -import {routes} from "@lodestar/api"; import {isValidMerkleBranch} from "./utils/verifyMerkleBranch.js"; import {assertZeroHashes, getParticipantPubkeys, isEmptyHeader} from "./utils/utils.js"; import {SyncCommitteeFast} from "./types.js"; @@ -65,7 +64,7 @@ export function assertValidLightClientUpdate( * * Where `hashTreeRoot(state) == update.finalityHeader.stateRoot` */ -export function assertValidFinalityProof(update: routes.lightclient.LightclientFinalizedUpdate): void { +export function assertValidFinalityProof(update: altair.LightClientFinalityUpdate): void { if ( !isValidMerkleBranch( ssz.phase0.BeaconBlockHeader.hashTreeRoot(update.finalizedHeader), diff --git a/packages/light-client/test/mocks/LightclientServerApiMock.ts b/packages/light-client/test/mocks/LightclientServerApiMock.ts index b6cbe6a35859..081e4168082d 100644 --- a/packages/light-client/test/mocks/LightclientServerApiMock.ts +++ b/packages/light-client/test/mocks/LightclientServerApiMock.ts @@ -7,9 +7,9 @@ import {BeaconStateAltair} from "../utils/types.js"; export class LightclientServerApiMock implements routes.lightclient.Api { readonly states = new Map(); readonly updates = new Map(); - readonly snapshots = new Map(); - latestHeadUpdate: routes.lightclient.LightclientOptimisticHeaderUpdate | null = null; - finalized: routes.lightclient.LightclientFinalizedUpdate | null = null; + readonly snapshots = new Map(); + latestHeadUpdate: altair.LightClientOptimisticUpdate | null = null; + finalized: altair.LightClientFinalityUpdate | null = null; async getStateProof(stateId: string, paths: JsonPath[]): Promise<{data: Proof}> { const state = this.states.get(stateId); @@ -28,17 +28,18 @@ export class LightclientServerApiMock implements routes.lightclient.Api { return {data: updates}; } - async getOptimisticUpdate(): Promise<{data: routes.lightclient.LightclientOptimisticHeaderUpdate}> { + async getOptimisticUpdate(): Promise<{data: altair.LightClientOptimisticUpdate}> { if (!this.latestHeadUpdate) throw Error("No latest head update"); return {data: this.latestHeadUpdate}; } - async getFinalityUpdate(): Promise<{data: routes.lightclient.LightclientFinalizedUpdate}> { + async getFinalityUpdate(): Promise<{data: altair.LightClientFinalityUpdate}> { if (!this.finalized) throw Error("No finalized head update"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment return {data: this.finalized}; } - async getBootstrap(blockRoot: string): Promise<{data: routes.lightclient.LightclientSnapshotWithProof}> { + async getBootstrap(blockRoot: string): Promise<{data: routes.lightclient.LightClientBootstrap}> { const snapshot = this.snapshots.get(blockRoot); if (!snapshot) throw Error(`snapshot for blockRoot ${blockRoot} not available`); return {data: snapshot}; diff --git a/packages/light-client/test/unit/sync.node.test.ts b/packages/light-client/test/unit/sync.node.test.ts index 56c3287b3278..089064e4719b 100644 --- a/packages/light-client/test/unit/sync.node.test.ts +++ b/packages/light-client/test/unit/sync.node.test.ts @@ -2,7 +2,7 @@ import {expect} from "chai"; import {init} from "@chainsafe/bls/switchable"; import {EPOCHS_PER_SYNC_COMMITTEE_PERIOD, SLOTS_PER_EPOCH} from "@lodestar/params"; import {BeaconStateAllForks, BeaconStateAltair} from "@lodestar/state-transition"; -import {phase0, ssz} from "@lodestar/types"; +import {altair, phase0, ssz} from "@lodestar/types"; import {routes, Api} from "@lodestar/api"; import {chainConfig as chainConfigDef} from "@lodestar/config/default"; import {createIBeaconConfig, IChainConfig} from "@lodestar/config"; @@ -79,7 +79,8 @@ describe("sync", () => { lightclientServerApi.latestHeadUpdate = committeeUpdateToLatestHeadUpdate(lastInMap(lightclientServerApi.updates)); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment lightclientServerApi.finalized = committeeUpdateToLatestFinalizedHeadUpdate( - lastInMap(lightclientServerApi.updates) + lastInMap(lightclientServerApi.updates), + targetSlot ); // Initialize from snapshot @@ -141,13 +142,14 @@ describe("sync", () => { bodyRoot: SOME_HASH, }; - const headUpdate: routes.lightclient.LightclientOptimisticHeaderUpdate = { + const headUpdate: altair.LightClientOptimisticUpdate = { attestedHeader: header, syncAggregate: syncCommittee.signHeader(config, header), + signatureSlot: header.slot + 1, }; lightclientServerApi.latestHeadUpdate = headUpdate; - eventsServerApi.emit({type: routes.events.EventType.lightclientOptimisticUpdate, message: headUpdate}); + eventsServerApi.emit({type: routes.events.EventType.lightClientOptimisticUpdate, message: headUpdate}); } }); diff --git a/packages/light-client/test/unit/validation.test.ts b/packages/light-client/test/unit/validation.test.ts index f8cf2ca9c4c0..f85f2fb9b311 100644 --- a/packages/light-client/test/unit/validation.test.ts +++ b/packages/light-client/test/unit/validation.test.ts @@ -35,7 +35,7 @@ describe("validation", function () { before("prepare data", function () { // Update slot must > snapshot slot - // updatePeriod must == snapshotPeriod + 1 + // attestedHeaderSlot must == updateHeaderSlot + 1 const snapshotHeaderSlot = 1; const updateHeaderSlot = EPOCHS_PER_SYNC_COMMITTEE_PERIOD * SLOTS_PER_EPOCH + 1; const attestedHeaderSlot = updateHeaderSlot + 1; @@ -95,6 +95,7 @@ describe("validation", function () { finalizedHeader, finalityBranch, syncAggregate, + signatureSlot: updateHeaderSlot, }; snapshot = { diff --git a/packages/light-client/test/utils/prepareUpdateNaive.ts b/packages/light-client/test/utils/prepareUpdateNaive.ts index 3db7010cf17d..f739d211f10c 100644 --- a/packages/light-client/test/utils/prepareUpdateNaive.ts +++ b/packages/light-client/test/utils/prepareUpdateNaive.ts @@ -95,5 +95,6 @@ export async function prepareUpdateNaive( finalizedHeader: finalizedCheckpointBlockHeader, finalityBranch: finalityBranch, syncAggregate, + signatureSlot: syncAttestedBlockHeader.slot + 1, }; } diff --git a/packages/light-client/test/utils/utils.ts b/packages/light-client/test/utils/utils.ts index 6533ede57e31..1e0e0a90f1e6 100644 --- a/packages/light-client/test/utils/utils.ts +++ b/packages/light-client/test/utils/utils.ts @@ -151,16 +151,17 @@ export function computeLightclientUpdate(config: IBeaconConfig, period: SyncPeri finalizedHeader, finalityBranch, syncAggregate, + signatureSlot: attestedHeader.slot + 1, }; } /** - * Creates a LightclientSnapshotWithProof that passes validation + * Creates a LightClientBootstrap that passes validation */ export function computeLightClientSnapshot( period: SyncPeriod ): { - snapshot: routes.lightclient.LightclientSnapshotWithProof; + snapshot: routes.lightclient.LightClientBootstrap; checkpointRoot: Uint8Array; } { const currentSyncCommittee = getInteropSyncCommittee(period).syncCommittee; @@ -247,19 +248,21 @@ export function computeMerkleBranch( export function committeeUpdateToLatestHeadUpdate( committeeUpdate: altair.LightClientUpdate -): routes.lightclient.LightclientOptimisticHeaderUpdate { +): altair.LightClientOptimisticUpdate { return { attestedHeader: committeeUpdate.attestedHeader, syncAggregate: { syncCommitteeBits: committeeUpdate.syncAggregate.syncCommitteeBits, syncCommitteeSignature: committeeUpdate.syncAggregate.syncCommitteeSignature, }, + signatureSlot: committeeUpdate.attestedHeader.slot + 1, }; } export function committeeUpdateToLatestFinalizedHeadUpdate( - committeeUpdate: altair.LightClientUpdate -): routes.lightclient.LightclientFinalizedUpdate { + committeeUpdate: altair.LightClientUpdate, + signatureSlot: Slot +): altair.LightClientFinalityUpdate { return { attestedHeader: committeeUpdate.attestedHeader, finalizedHeader: committeeUpdate.finalizedHeader, @@ -268,6 +271,7 @@ export function committeeUpdateToLatestFinalizedHeadUpdate( syncCommitteeBits: committeeUpdate.syncAggregate.syncCommitteeBits, syncCommitteeSignature: committeeUpdate.syncAggregate.syncCommitteeSignature, }, + signatureSlot, }; } diff --git a/packages/params/src/index.ts b/packages/params/src/index.ts index 43ee55e84e5b..1fca24764c72 100644 --- a/packages/params/src/index.ts +++ b/packages/params/src/index.ts @@ -197,6 +197,7 @@ export const NEXT_SYNC_COMMITTEE_GINDEX = 55; */ export const NEXT_SYNC_COMMITTEE_DEPTH = 5; export const NEXT_SYNC_COMMITTEE_INDEX = 23; +export const MAX_REQUEST_LIGHT_CLIENT_UPDATES = 128; /** * Optimistic sync diff --git a/packages/types/src/altair/sszTypes.ts b/packages/types/src/altair/sszTypes.ts index 4e4a03a317da..952a02b75338 100644 --- a/packages/types/src/altair/sszTypes.ts +++ b/packages/types/src/altair/sszTypes.ts @@ -182,13 +182,13 @@ export const BeaconState = new ContainerType( {typeName: "BeaconState", jsonCase: "eth2"} ); -export const LightClientSnapshot = new ContainerType( +export const LightClientBootstrap = new ContainerType( { header: phase0Ssz.BeaconBlockHeader, currentSyncCommittee: SyncCommittee, - nextSyncCommittee: SyncCommittee, + currentSyncCommitteeBranch: new VectorCompositeType(Bytes32, NEXT_SYNC_COMMITTEE_DEPTH), }, - {typeName: "LightClientSnapshot", jsonCase: "eth2"} + {typeName: "LightClientBootstrap", jsonCase: "eth2"} ); export const LightClientUpdate = new ContainerType( @@ -199,13 +199,42 @@ export const LightClientUpdate = new ContainerType( finalizedHeader: phase0Ssz.BeaconBlockHeader, finalityBranch: new VectorCompositeType(Bytes32, FINALIZED_ROOT_DEPTH), syncAggregate: SyncAggregate, + signatureSlot: Slot, }, {typeName: "LightClientUpdate", jsonCase: "eth2"} ); +export const LightClientFinalityUpdate = new ContainerType( + { + attestedHeader: phase0Ssz.BeaconBlockHeader, + finalizedHeader: phase0Ssz.BeaconBlockHeader, + finalityBranch: new VectorCompositeType(Bytes32, FINALIZED_ROOT_DEPTH), + syncAggregate: SyncAggregate, + signatureSlot: Slot, + }, + {typeName: "LightClientFinalityUpdate", jsonCase: "eth2"} +); + +export const LightClientOptimisticUpdate = new ContainerType( + { + attestedHeader: phase0Ssz.BeaconBlockHeader, + syncAggregate: SyncAggregate, + signatureSlot: Slot, + }, + {typeName: "LightClientOptimisticUpdate", jsonCase: "eth2"} +); + +export const LightClientUpdatesByRange = new ContainerType( + { + startPeriod: UintNum64, + count: UintNum64, + }, + {typeName: "LightClientUpdatesByRange", jsonCase: "eth2"} +); + export const LightClientStore = new ContainerType( { - snapshot: LightClientSnapshot, + snapshot: LightClientBootstrap, validUpdates: new ListCompositeType(LightClientUpdate, EPOCHS_PER_SYNC_COMMITTEE_PERIOD * SLOTS_PER_EPOCH), }, {typeName: "LightClientStore", jsonCase: "eth2"} diff --git a/packages/types/src/altair/types.ts b/packages/types/src/altair/types.ts index 7a58c46f2089..d15eef050f10 100644 --- a/packages/types/src/altair/types.ts +++ b/packages/types/src/altair/types.ts @@ -14,6 +14,8 @@ export type BeaconBlockBody = ValueOf; export type BeaconBlock = ValueOf; export type SignedBeaconBlock = ValueOf; export type BeaconState = ValueOf; -export type LightClientSnapshot = ValueOf; +export type LightClientBootstrap = ValueOf; export type LightClientUpdate = ValueOf; -export type LightClientStore = ValueOf; +export type LightClientFinalityUpdate = ValueOf; +export type LightClientOptimisticUpdate = ValueOf; +export type LightClientUpdatesByRange = ValueOf; diff --git a/packages/validator/src/services/syncCommittee.ts b/packages/validator/src/services/syncCommittee.ts index 6d9b17703ea0..b8d58d768af8 100644 --- a/packages/validator/src/services/syncCommittee.ts +++ b/packages/validator/src/services/syncCommittee.ts @@ -93,7 +93,7 @@ export class SyncCommitteeService { * Performs the first step of the attesting process: downloading `SyncCommittee` objects, * signing them and returning them to the validator. * - * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/validator.md#attesting + * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/altair/validator.md#sync-committee-messages * * Only one `SyncCommittee` is downloaded from the BN. It is then signed by each * validator and the list of individually-signed `SyncCommittee` objects is returned to the BN. @@ -160,7 +160,7 @@ export class SyncCommitteeService { * Performs the second step of the attesting process: downloading an aggregated `SyncCommittee`, * converting it into a `SignedAggregateAndProof` and returning it to the BN. * - * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/validator.md#broadcast-aggregate + * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/altair/validator.md#sync-committee-contributions * * Only one aggregated `SyncCommittee` is downloaded from the BN. It is then signed * by each validator and the list of individually-signed `SignedAggregateAndProof` objects is diff --git a/scripts/dev/node1.sh b/scripts/dev/node1.sh index da876db972cd..b7a3d86caa2f 100755 --- a/scripts/dev/node1.sh +++ b/scripts/dev/node1.sh @@ -4,7 +4,7 @@ GENESIS_TIME=$(date +%s) packages/cli/bin/lodestar dev \ --genesisValidators 8 \ - --startValidators 0:7 \ + --startValidators 0..7 \ --genesisTime $GENESIS_TIME \ --enr.ip 127.0.0.1 \ --dataDir .lodestar/node1 \