diff --git a/packages/beacon-node/src/chain/lightClient/index.ts b/packages/beacon-node/src/chain/lightClient/index.ts index 040aa1fac401..2b79dd1fef1e 100644 --- a/packages/beacon-node/src/chain/lightClient/index.ts +++ b/packages/beacon-node/src/chain/lightClient/index.ts @@ -1,6 +1,12 @@ import {altair, phase0, Root, RootHex, Slot, ssz, SyncPeriod} from "@lodestar/types"; import {IChainForkConfig} from "@lodestar/config"; -import {CachedBeaconStateAltair, computeSyncPeriodAtEpoch, computeSyncPeriodAtSlot} from "@lodestar/state-transition"; +import { + CachedBeaconStateAltair, + computeStartSlotAtEpoch, + computeSyncPeriodAtEpoch, + computeSyncPeriodAtSlot, +} from "@lodestar/state-transition"; +import {isBetterUpdate, toLightClientUpdateSummary, LightClientUpdateSummary} from "@lodestar/light-client/spec"; import {ILogger, MapDef, pruneSetToMax} from "@lodestar/utils"; import {BitArray, CompositeViewDU, toHexString} from "@chainsafe/ssz"; import {MIN_SYNC_COMMITTEE_PARTICIPANTS, SYNC_COMMITTEE_SIZE} from "@lodestar/params"; @@ -24,7 +30,7 @@ export type LightClientServerOpts = { type DependantRootHex = RootHex; type BlockRooHex = RootHex; -type SyncAttestedData = { +export type SyncAttestedData = { attestedHeader: phase0.BeaconBlockHeader; /** Precomputed root to prevent re-hashing */ blockRoot: Uint8Array; @@ -534,9 +540,29 @@ export class LightClientServer { attestedData: SyncAttestedData ): Promise { const prevBestUpdate = await this.db.bestLightClientUpdate.get(syncPeriod); - if (prevBestUpdate && !isBetterUpdate(prevBestUpdate, syncAggregate, attestedData)) { - this.metrics?.lightclientServer.updateNotBetter.inc(); - return; + + if (prevBestUpdate) { + const prevBestUpdateSummary = toLightClientUpdateSummary(prevBestUpdate); + + const nextBestUpdate: LightClientUpdateSummary = { + activeParticipants: sumBits(syncAggregate.syncCommitteeBits), + attestedHeaderSlot: attestedData.attestedHeader.slot, + signatureSlot, + // The actual finalizedHeader is fetched below. To prevent a DB read we approximate the actual slot. + // If update is not finalized finalizedHeaderSlot does not matter (see is_better_update), so setting + // to zero to set it some number. + finalizedHeaderSlot: attestedData.isFinalized + ? computeStartSlotAtEpoch(attestedData.finalizedCheckpoint.epoch) + : 0, + // All updates include a valid `nextSyncCommitteeBranch`, see below code + isSyncCommitteeUpdate: true, + isFinalityUpdate: attestedData.isFinalized, + }; + + if (!isBetterUpdate(prevBestUpdateSummary, nextBestUpdate)) { + this.metrics?.lightclientServer.updateNotBetter.inc(); + return; + } } const syncCommitteeWitness = await this.db.syncCommitteeWitness.get(attestedData.blockRoot); @@ -636,42 +662,6 @@ export class LightClientServer { } } -/** - * Returns the update with more bits. On ties, prevUpdate is the better - * - * Spec v1.0.1 - * ```python - * max(store.valid_updates, key=lambda update: sum(update.sync_committee_bits))) - * ``` - */ -export function isBetterUpdate( - prevUpdate: altair.LightClientUpdate, - nextSyncAggregate: altair.SyncAggregate, - nextSyncAttestedData: SyncAttestedData -): boolean { - const nextBitCount = sumBits(nextSyncAggregate.syncCommitteeBits); - - // Finalized if participation is over 66% - if ( - ssz.altair.LightClientUpdate.fields["finalityBranch"].equals( - ssz.altair.LightClientUpdate.fields["finalityBranch"].defaultValue(), - prevUpdate.finalityBranch - ) && - nextSyncAttestedData.isFinalized && - nextBitCount * 3 > SYNC_COMMITTEE_SIZE * 2 - ) { - return true; - } - - // Higher bit count - const prevBitCount = sumBits(prevUpdate.syncAggregate.syncCommitteeBits); - if (prevBitCount > nextBitCount) return false; - if (prevBitCount < nextBitCount) return true; - - // else keep the oldest, lowest chance or re-org and requires less updating - return prevUpdate.attestedHeader.slot > nextSyncAttestedData.attestedHeader.slot; -} - export function sumBits(bits: BitArray): number { return bits.getTrueBitIndexes().length; } diff --git a/packages/beacon-node/test/spec/presets/index.test.ts b/packages/beacon-node/test/spec/presets/index.test.ts index a77e5c46f3da..acb034ea7dfe 100644 --- a/packages/beacon-node/test/spec/presets/index.test.ts +++ b/packages/beacon-node/test/spec/presets/index.test.ts @@ -8,6 +8,7 @@ import {finality} from "./finality.js"; import {fork} from "./fork.js"; import {forkChoiceTest} from "./fork_choice.js"; import {genesis} from "./genesis.js"; +import {lightClient} from "./light_client/index.js"; import {merkle} from "./merkle.js"; import {operations} from "./operations.js"; import {rewards} from "./rewards.js"; @@ -20,7 +21,7 @@ import {transition} from "./transition.js"; // because the latest withdrawals we implemented are a breaking change const skipOpts: SkipOpts = { skippedForks: [], - skippedRunners: ["light_client", "sync"], + skippedRunners: ["sync"], skippedHandlers: ["full_withdrawals", "partial_withdrawals", "bls_to_execution_change", "withdrawals"], }; @@ -34,6 +35,7 @@ specTestIterator( fork: {type: RunnerType.default, fn: fork}, fork_choice: {type: RunnerType.default, fn: forkChoiceTest}, genesis: {type: RunnerType.default, fn: genesis}, + light_client: {type: RunnerType.default, fn: lightClient}, merkle: {type: RunnerType.default, fn: merkle}, operations: {type: RunnerType.default, fn: operations}, random: {type: RunnerType.default, fn: sanityBlocks}, diff --git a/packages/beacon-node/test/spec/presets/light_client/index.ts b/packages/beacon-node/test/spec/presets/light_client/index.ts new file mode 100644 index 000000000000..ce2a0ab5a8fd --- /dev/null +++ b/packages/beacon-node/test/spec/presets/light_client/index.ts @@ -0,0 +1,21 @@ +import {TestRunnerFn} from "../../utils/types.js"; +import {singleMerkleProof} from "./single_merkle_proof.js"; +import {sync} from "./sync.js"; +import {updateRanking} from "./update_ranking.js"; + +/* eslint-disable @typescript-eslint/naming-convention */ + +export const lightClient: TestRunnerFn = (fork, testName, testSuite) => { + const testFn = lightclientTestFns[testName]; + if (testFn === undefined) { + throw Error(`Unknown lightclient test ${testName}`); + } + + return testFn(fork, testName, testSuite); +}; + +const lightclientTestFns: Record> = { + single_merkle_proof: singleMerkleProof, + sync: sync, + update_ranking: updateRanking, +}; diff --git a/packages/beacon-node/test/spec/presets/light_client/single_merkle_proof.ts b/packages/beacon-node/test/spec/presets/light_client/single_merkle_proof.ts new file mode 100644 index 000000000000..6f8da70f5dd6 --- /dev/null +++ b/packages/beacon-node/test/spec/presets/light_client/single_merkle_proof.ts @@ -0,0 +1,57 @@ +import {expect} from "chai"; +import {RootHex, ssz} from "@lodestar/types"; +import {InputType} from "@lodestar/spec-test-util"; +import {ForkName} from "@lodestar/params"; +import {Tree} from "@chainsafe/persistent-merkle-tree"; +import {TreeViewDU, Type} from "@chainsafe/ssz"; +import {toHex} from "@lodestar/utils"; +import {TestRunnerFn} from "../../utils/types.js"; + +/* eslint-disable @typescript-eslint/naming-convention */ + +// https://github.com/ethereum/consensus-specs/blob/da3f5af919be4abb5a6db5a80b235deb8b4b5cba/tests/formats/light_client/single_merkle_proof.md +type SingleMerkleProofTestCase = { + meta?: any; + object: TreeViewDU; + // leaf: Bytes32 # string, hex encoded, with 0x prefix + // leaf_index: int # integer, decimal + // branch: list of Bytes32 # list, each element is a string, hex encoded, with 0x prefix + proof: { + leaf: RootHex; + leaf_index: bigint; + branch: RootHex[]; + }; +}; + +export const singleMerkleProof: TestRunnerFn = (fork, testHandler, testSuite) => { + return { + testFunction: (testcase) => { + // Assert correct proof generation + const branch = new Tree(testcase.object.node).getSingleProof(testcase.proof.leaf_index); + return branch.map(toHex); + }, + options: { + inputTypes: { + object: InputType.SSZ_SNAPPY, + proof: InputType.YAML, + }, + sszTypes: { + object: getObjectType(fork, testSuite), + }, + getExpected: (testCase) => testCase.proof.branch, + expectFunc: (testCase, expected, actual) => { + expect(actual).deep.equals(expected); + }, + // Do not manually skip tests here, do it in packages/beacon-node/test/spec/presets/index.test.ts + }, + }; +}; + +function getObjectType(fork: ForkName, objectName: string): Type { + switch (objectName) { + case "BeaconState": + return ssz[fork].BeaconState; + default: + throw Error(`Unknown objectName ${objectName}`); + } +} diff --git a/packages/beacon-node/test/spec/presets/light_client/sync.ts b/packages/beacon-node/test/spec/presets/light_client/sync.ts new file mode 100644 index 000000000000..7a4920ee3867 --- /dev/null +++ b/packages/beacon-node/test/spec/presets/light_client/sync.ts @@ -0,0 +1,205 @@ +import {expect} from "chai"; +import {altair, phase0, RootHex, Slot, ssz} from "@lodestar/types"; +import {init} from "@chainsafe/bls/switchable"; +import {InputType} from "@lodestar/spec-test-util"; +import {createIBeaconConfig, IChainConfig} from "@lodestar/config"; +import {fromHex, toHex} from "@lodestar/utils"; +import {LightclientSpec, toLightClientUpdateSummary} from "@lodestar/light-client/spec"; +import {computeSyncPeriodAtSlot} from "@lodestar/state-transition"; +import {TestRunnerFn} from "../../utils/types.js"; +import {testLogger} from "../../../utils/logger.js"; + +/* eslint-disable @typescript-eslint/naming-convention */ + +// https://github.com/ethereum/consensus-specs/blob/da3f5af919be4abb5a6db5a80b235deb8b4b5cba/tests/formats/light_client/single_merkle_proof.md +type SyncTestCase = { + meta: { + genesis_validators_root: RootHex; + trusted_block_root: RootHex; + }; + steps: LightclientSyncSteps[]; + config: Partial; + bootstrap: altair.LightClientBootstrap; + + // leaf: Bytes32 # string, hex encoded, with 0x prefix + // leaf_index: int # integer, decimal + // branch: list of Bytes32 # list, each element is a string, hex encoded, with 0x prefix + proof: { + leaf: RootHex; + leaf_index: bigint; + branch: RootHex[]; + }; + + // Injected after parsing + updates: Map; +}; + +type CheckHeader = { + slot: bigint; + beacon_root: RootHex; +}; + +type Checks = { + /** store.finalized_header */ + finalized_header: CheckHeader; + /** store.optimistic_header */ + optimistic_header: CheckHeader; +}; + +// - process_update: +// update: update_0x460ec66196a5732b306791e82a0d949b49be812cf09b72667fe90735994c3b68_xx +// current_slot: 97 +// checks: +// finalized_header: {slot: 72, beacon_root: '0x36c5a33d8843f26749697a72de42b5bf621c760502847fdb6d50c1e0f1a04ac1'} +// optimistic_header: {slot: 96, beacon_root: '0x460ec66196a5732b306791e82a0d949b49be812cf09b72667fe90735994c3b68'} + +type ProcessUpdateStep = { + process_update: { + update: string; + current_slot: bigint; + checks: Checks; + }; +}; + +type ForceUpdateStep = { + force_update: { + current_slot: bigint; + checks: Checks; + }; +}; + +type LightclientSyncSteps = ProcessUpdateStep | ForceUpdateStep; + +const logger = testLogger("spec-test"); +const UPDATE_FILE_NAME = "^(update)_([0-9a-zA-Z_]+)$"; + +export const sync: TestRunnerFn = () => { + return { + testFunction: async (testcase) => { + await init("blst-native"); + + // Grab only the ALTAIR_FORK_EPOCH, since the domains are the same as minimal + const config = createIBeaconConfig( + pickConfigForkEpochs(testcase.config), + fromHex(testcase.meta.genesis_validators_root) + ); + + const lightClientOpts = { + allowForcedUpdates: true, + updateHeadersOnForcedUpdate: true, + }; + const lightClient = new LightclientSpec(config, lightClientOpts, testcase.bootstrap); + + const stepsLen = testcase.steps.length; + + function toHeaderSummary(header: phase0.BeaconBlockHeader): {root: string; slot: number} { + return { + root: toHex(ssz.phase0.BeaconBlockHeader.hashTreeRoot(header)), + slot: header.slot, + }; + } + + function assertHeader(actualHeader: phase0.BeaconBlockHeader, expectedHeader: CheckHeader, msg: string): void { + expect(toHeaderSummary(actualHeader)).deep.equals( + {root: expectedHeader.beacon_root, slot: Number(expectedHeader.slot as bigint)}, + msg + ); + } + + function runChecks(update: {checks: Checks}): void { + assertHeader(lightClient.store.finalizedHeader, update.checks.finalized_header, "wrong finalizedHeader"); + assertHeader(lightClient.store.optimisticHeader, update.checks.optimistic_header, "wrong optimisticHeader"); + } + + function renderSlot(currentSlot: Slot): {currentSlot: number; curretPeriod: number} { + return {currentSlot, curretPeriod: computeSyncPeriodAtSlot(currentSlot)}; + } + + for (const [i, step] of testcase.steps.entries()) { + try { + if (isProcessUpdateStep(step)) { + const currentSlot = Number(step.process_update.current_slot as bigint); + logger.debug(`Step ${i}/${stepsLen} process_update`, renderSlot(currentSlot)); + + const update = testcase.updates.get(step.process_update.update); + if (!update) { + throw Error(`update ${step.process_update.update} not found`); + } + + logger.debug(`LightclientUpdateSummary: ${JSON.stringify(toLightClientUpdateSummary(update))}`); + + lightClient.onUpdate(currentSlot, update); + runChecks(step.process_update); + } + + // force_update step + else if (isForceUpdateStep(step)) { + const currentSlot = Number(step.force_update.current_slot as bigint); + logger.debug(`Step ${i}/${stepsLen} force_update`, renderSlot(currentSlot)); + + // Simulate force_update() + lightClient.forceUpdate(currentSlot); + + // lightClient.forceUpdate(); + runChecks(step.force_update); + } + + logger.debug( + `finalizedHeader = ${JSON.stringify(toHeaderSummary(lightClient.store.finalizedHeader))}` + + ` optimisticHeader = ${JSON.stringify(toHeaderSummary(lightClient.store.optimisticHeader))}` + ); + } catch (e) { + (e as Error).message = `Error on step ${i}/${stepsLen}: ${(e as Error).message}`; + throw e; + } + } + }, + options: { + inputTypes: { + meta: InputType.YAML, + steps: InputType.YAML, + config: InputType.YAML, + }, + sszTypes: { + bootstrap: ssz.altair.LightClientBootstrap, + [UPDATE_FILE_NAME]: ssz.altair.LightClientUpdate, + }, + mapToTestCase: (t: Record) => { + // t has input file name as key + const updates = new Map(); + for (const key in t) { + const updateMatch = key.match(UPDATE_FILE_NAME); + if (updateMatch) { + updates.set(key, t[key]); + } + } + return { + ...t, + updates, + } as SyncTestCase; + }, + timeout: 10000, + // eslint-disable-next-line @typescript-eslint/no-empty-function + expectFunc: () => {}, + // Do not manually skip tests here, do it in packages/beacon-node/test/spec/presets/index.test.ts + }, + }; +}; + +function pickConfigForkEpochs(config: Partial): Partial { + const configOnlyFork: Record = {}; + for (const key of Object.keys(config) as (keyof IChainConfig)[]) { + if (key.endsWith("_FORK_EPOCH")) { + configOnlyFork[key] = config[key] as number; + } + } + return configOnlyFork; +} + +function isProcessUpdateStep(step: unknown): step is ProcessUpdateStep { + return (step as ProcessUpdateStep).process_update !== undefined; +} + +function isForceUpdateStep(step: unknown): step is ForceUpdateStep { + return (step as ForceUpdateStep).force_update !== undefined; +} diff --git a/packages/beacon-node/test/spec/presets/light_client/update_ranking.ts b/packages/beacon-node/test/spec/presets/light_client/update_ranking.ts new file mode 100644 index 000000000000..533713c65667 --- /dev/null +++ b/packages/beacon-node/test/spec/presets/light_client/update_ranking.ts @@ -0,0 +1,66 @@ +import {expect} from "chai"; +import {altair, ssz} from "@lodestar/types"; +import {InputType} from "@lodestar/spec-test-util"; +import {isBetterUpdate, LightClientUpdateSummary, toLightClientUpdateSummary} from "@lodestar/light-client/spec"; +import {TestRunnerFn} from "../../utils/types.js"; + +/* eslint-disable @typescript-eslint/naming-convention */ + +// https://github.com/ethereum/consensus-specs/blob/da3f5af919be4abb5a6db5a80b235deb8b4b5cba/tests/formats/light_client/update_ranking.md +type UpdateRankingTestCase = { + meta: { + updates_count: bigint; + }; +}; + +// updates_.ssz_snappy +const UPDATES_FILE_NAME = "^updates_([0-9]+)$"; + +export const updateRanking: TestRunnerFn = () => { + return { + testFunction: (testcase) => { + // Parse update files + const updatesCount = Number(testcase.meta.updates_count as bigint); + const updates: altair.LightClientUpdate[] = []; + + for (let i = 0; i < updatesCount; i++) { + const update = ((testcase as unknown) as Record)[`updates_${i}`]; + if (update === undefined) { + throw Error(`no update for index ${i}`); + } + updates[i] = update; + } + + // A test-runner should load the provided update objects and verify that the local implementation ranks them in the same order + // best update at index 0 + for (let i = 0; i < updatesCount - 1; i++) { + const newUpdate = toLightClientUpdateSummary(updates[i]); + const oldUpdate = toLightClientUpdateSummary(updates[i + 1]); + + expect(isBetterUpdate(newUpdate, oldUpdate)).equals( + true, + // Print update summary for easier debugging + `update ${i} must be better than ${i + 1} +oldUpdate = ${renderUpdate(oldUpdate)} +newUpdate = ${renderUpdate(newUpdate)} +` + ); + } + }, + options: { + inputTypes: { + meta: InputType.YAML, + }, + sszTypes: { + [UPDATES_FILE_NAME]: ssz.altair.LightClientUpdate, + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + expectFunc: () => {}, + // Do not manually skip tests here, do it in packages/beacon-node/test/spec/presets/index.test.ts + }, + }; +}; + +function renderUpdate(update: LightClientUpdateSummary): string { + return JSON.stringify(update, null, 2); +} diff --git a/packages/config/src/chainConfig/json.ts b/packages/config/src/chainConfig/json.ts index a9f71cea063f..03a61184236e 100644 --- a/packages/config/src/chainConfig/json.ts +++ b/packages/config/src/chainConfig/json.ts @@ -22,7 +22,7 @@ export function chainConfigFromJson(json: Record): IChainConfig for (const key of Object.keys(chainConfigTypes) as (keyof IChainConfig)[]) { const value = json[key]; if (value !== undefined) { - config[key] = deserializeSpecValue(json[key], chainConfigTypes[key]) as never; + config[key] = deserializeSpecValue(json[key], chainConfigTypes[key], key) as never; } } @@ -79,9 +79,9 @@ export function serializeSpecValue(value: SpecValue, typeName: SpecValueTypeName } } -export function deserializeSpecValue(valueStr: unknown, typeName: SpecValueTypeName): SpecValue { +export function deserializeSpecValue(valueStr: unknown, typeName: SpecValueTypeName, keyName: string): SpecValue { if (typeof valueStr !== "string") { - throw Error(`Invalid value ${valueStr} expected string`); + throw Error(`Invalid ${keyName} value ${valueStr} expected string`); } switch (typeName) { diff --git a/packages/light-client/package.json b/packages/light-client/package.json index e5aa0e7a5185..f129f53423ae 100644 --- a/packages/light-client/package.json +++ b/packages/light-client/package.json @@ -22,6 +22,9 @@ }, "./validation": { "import": "./lib/validation.js" + }, + "./spec": { + "import": "./lib/spec/index.js" } }, "types": "./lib/index.d.ts", diff --git a/packages/light-client/src/events.ts b/packages/light-client/src/events.ts index 0bf90eb0ee98..9514844e5b72 100644 --- a/packages/light-client/src/events.ts +++ b/packages/light-client/src/events.ts @@ -1,4 +1,4 @@ -import {phase0, SyncPeriod} from "@lodestar/types"; +import {phase0} from "@lodestar/types"; export enum LightclientEvent { /** @@ -9,17 +9,11 @@ export enum LightclientEvent { * New finalized */ finalized = "finalized", - /** - * Stored nextSyncCommittee from an update at period `period`. - * Note: the SyncCommittee is stored for `period + 1`. - */ - committee = "committee", } export type LightclientEvents = { [LightclientEvent.head]: (newHeader: phase0.BeaconBlockHeader) => void; [LightclientEvent.finalized]: (newHeader: phase0.BeaconBlockHeader) => void; - [LightclientEvent.committee]: (updatePeriod: SyncPeriod) => void; }; export type LightclientEmitter = MittEmitter; diff --git a/packages/light-client/src/index.ts b/packages/light-client/src/index.ts index 25051bd15da5..07e856b376d8 100644 --- a/packages/light-client/src/index.ts +++ b/packages/light-client/src/index.ts @@ -1,23 +1,21 @@ import mitt from "mitt"; import {init as initBls} from "@chainsafe/bls/switchable"; -import {EPOCHS_PER_SYNC_COMMITTEE_PERIOD, SLOTS_PER_EPOCH} from "@lodestar/params"; +import {EPOCHS_PER_SYNC_COMMITTEE_PERIOD} from "@lodestar/params"; import {getClient, Api, routes} from "@lodestar/api"; -import {altair, phase0, RootHex, ssz, SyncPeriod} from "@lodestar/types"; +import {altair, phase0, RootHex, Slot, SyncPeriod} from "@lodestar/types"; import {createIBeaconConfig, IBeaconConfig, IChainForkConfig} from "@lodestar/config"; import {TreeOffsetProof} from "@chainsafe/persistent-merkle-tree"; import {isErrorAborted, sleep} from "@lodestar/utils"; import {fromHexString, JsonPath, toHexString} from "@chainsafe/ssz"; import {getCurrentSlot, slotWithFutureTolerance, timeUntilNextEpoch} from "./utils/clock.js"; -import {isBetterUpdate, LightclientUpdateStats} from "./utils/update.js"; -import {deserializeSyncCommittee, isEmptyHeader, isNode, sumBits} from "./utils/utils.js"; -import {pruneSetToMax} from "./utils/map.js"; -import {isValidMerkleBranch} from "./utils/verifyMerkleBranch.js"; -import {SyncCommitteeFast} from "./types.js"; +import {isNode} from "./utils/utils.js"; import {chunkifyInclusiveRange} from "./utils/chunkify.js"; import {LightclientEmitter, LightclientEvent} from "./events.js"; -import {assertValidSignedHeader, assertValidLightClientUpdate, assertValidFinalityProof} from "./validation.js"; import {getLcLoggerConsole, ILcLogger} from "./utils/logger.js"; import {computeSyncPeriodAtEpoch, computeSyncPeriodAtSlot, computeEpochAtSlot} from "./utils/clock.js"; +import {LightclientSpec} from "./spec/index.js"; +import {validateLightClientBootstrap} from "./spec/validateLightClientBootstrap.js"; +import {ProcessUpdateOpts} from "./spec/processLightClientUpdate.js"; // Re-export types export {LightclientEvent} from "./events.js"; @@ -28,36 +26,28 @@ export type GenesisData = { genesisValidatorsRoot: RootHex | Uint8Array; }; +export type LightclientOpts = ProcessUpdateOpts; + export type LightclientInitArgs = { config: IChainForkConfig; logger?: ILcLogger; + opts?: LightclientOpts; genesisData: GenesisData; beaconApiUrl: string; - snapshot: { - header: phase0.BeaconBlockHeader; - currentSyncCommittee: altair.SyncCommittee; - }; + bootstrap: altair.LightClientBootstrap; }; /** Provides some protection against a server client sending header updates too far away in the future */ -const MAX_CLOCK_DISPARITY_SEC = 12; +const MAX_CLOCK_DISPARITY_SEC = 10; /** Prevent responses that are too big and get truncated. No specific reasoning for 32 */ const MAX_PERIODS_PER_REQUEST = 32; /** For mainnet preset 8 epochs, for minimal preset `EPOCHS_PER_SYNC_COMMITTEE_PERIOD / 2` */ const LOOKAHEAD_EPOCHS_COMMITTEE_SYNC = Math.min(8, Math.ceil(EPOCHS_PER_SYNC_COMMITTEE_PERIOD / 2)); /** Prevent infinite loops caused by sync errors */ const ON_ERROR_RETRY_MS = 1000; -/** Persist only the current and next sync committee */ -const MAX_STORED_SYNC_COMMITTEES = 2; -/** Persist current previous and next participation */ -const MAX_STORED_PARTICIPATION = 3; -/** - * From https://notes.ethereum.org/@vbuterin/extended_light_client_protocol#Optimistic-head-determining-function - */ -const SAFETY_THRESHOLD_FACTOR = 2; -const CURRENT_SYNC_COMMITTEE_INDEX = 22; -const CURRENT_SYNC_COMMITTEE_DEPTH = 5; +// TODO: Customize with option +const ALLOW_FORCED_UPDATES = true; enum RunStatusCode { started, @@ -87,7 +77,7 @@ type RunStatus = * - For unknown test networks it can be queried from a trusted node at GET beacon/genesis * - `beaconApiUrl`: To connect to a trustless beacon node * - `LightclientStore`: To have an initial trusted SyncCommittee to start the sync - * - For new lightclient instances, it can be queries from a trustless node at GET lightclient/snapshot + * - For new lightclient instances, it can be queries from a trustless node at GET lightclient/bootstrap * - For existing lightclient instances, it should be retrieved from storage * * When to trigger a committee update sync: @@ -114,32 +104,11 @@ export class Lightclient { readonly genesisTime: number; readonly beaconApiUrl: string; - /** - * Map of period -> SyncCommittee. Uses a Map instead of spec's current and next fields to allow more flexible sync - * strategies. In this case the Lightclient won't attempt to fetch the next SyncCommittee until the end of the - * current period. This Map approach is also flexible in case header updates arrive in mixed ordering. - */ - readonly syncCommitteeByPeriod = new Map(); - /** - * Register participation by period. Lightclient only accepts updates that have sufficient participation compared to - * previous updates with a factor of SAFETY_THRESHOLD_FACTOR. - */ - private readonly maxParticipationByPeriod = new Map(); - private head: { - participation: number; - header: phase0.BeaconBlockHeader; - blockRoot: RootHex; - }; - - private finalized: { - participation: number; - header: phase0.BeaconBlockHeader; - blockRoot: RootHex; - } | null = null; + private readonly lightclientSpec: LightclientSpec; private status: RunStatus = {code: RunStatusCode.stopped}; - constructor({config, logger, genesisData, beaconApiUrl, snapshot}: LightclientInitArgs) { + constructor({config, logger, genesisData, beaconApiUrl, bootstrap}: LightclientInitArgs) { this.genesisTime = genesisData.genesisTime; this.genesisValidatorsRoot = typeof genesisData.genesisValidatorsRoot === "string" @@ -152,19 +121,21 @@ export class Lightclient { this.beaconApiUrl = beaconApiUrl; this.api = getClient({baseUrl: beaconApiUrl}, {config}); - const periodCurr = computeSyncPeriodAtSlot(snapshot.header.slot); - this.syncCommitteeByPeriod.set(periodCurr, { - isFinalized: false, - participation: 0, - slot: periodCurr * EPOCHS_PER_SYNC_COMMITTEE_PERIOD * SLOTS_PER_EPOCH, - ...deserializeSyncCommittee(snapshot.currentSyncCommittee), - }); - - this.head = { - participation: 0, - header: snapshot.header, - blockRoot: toHexString(ssz.phase0.BeaconBlockHeader.hashTreeRoot(snapshot.header)), - }; + this.lightclientSpec = new LightclientSpec( + this.config, + { + allowForcedUpdates: ALLOW_FORCED_UPDATES, + onSetFinalizedHeader: (header) => { + this.emitter.emit(LightclientEvent.finalized, header); + this.logger.debug("Updated state.finalizedHeader", {slot: header.slot}); + }, + onSetOptimisticHeader: (header) => { + this.emitter.emit(LightclientEvent.head, header); + this.logger.debug("Updated state.optimisticHeader", {slot: header.slot}); + }, + }, + bootstrap + ); } // Embed lightweight clock. The epoch cycles are handled with `this.runLoop()` @@ -172,19 +143,13 @@ export class Lightclient { return getCurrentSlot(this.config, this.genesisTime); } - static async initializeFromCheckpointRoot({ - config, - logger, - beaconApiUrl, - genesisData, - checkpointRoot, - }: { - config: IChainForkConfig; - logger?: ILcLogger; - beaconApiUrl: string; - genesisData: GenesisData; - checkpointRoot: phase0.Checkpoint["root"]; - }): Promise { + static async initializeFromCheckpointRoot( + args: Omit & { + checkpointRoot: phase0.Checkpoint["root"]; + } + ): Promise { + const {config, beaconApiUrl, checkpointRoot} = args; + // Initialize the BLS implementation. This may requires initializing the WebAssembly instance // so why it's a an async process. This should be initialized once before any bls operations. // This process has to be done manually because of an issue in Karma runner @@ -194,35 +159,11 @@ export class Lightclient { const api = getClient({baseUrl: beaconApiUrl}, {config}); // Fetch bootstrap state with proof at the trusted block root - const {data: bootstrapStateWithProof} = await api.lightclient.getBootstrap(toHexString(checkpointRoot)); - const {header, currentSyncCommittee, currentSyncCommitteeBranch} = bootstrapStateWithProof; + const {data: bootstrap} = await api.lightclient.getBootstrap(toHexString(checkpointRoot)); - // verify the response matches the requested root - const headerRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(header); - if (!ssz.Root.equals(checkpointRoot, headerRoot)) { - throw new Error("Snapshot header does not match trusted checkpoint"); - } - - // Verify the sync committees - if ( - !isValidMerkleBranch( - ssz.altair.SyncCommittee.hashTreeRoot(currentSyncCommittee), - currentSyncCommitteeBranch, - CURRENT_SYNC_COMMITTEE_DEPTH, - CURRENT_SYNC_COMMITTEE_INDEX, - header.stateRoot as Uint8Array - ) - ) { - throw Error("Snapshot sync committees proof does not match trusted checkpoint"); - } + validateLightClientBootstrap(checkpointRoot, bootstrap); - return new Lightclient({ - config, - logger, - beaconApiUrl, - genesisData, - snapshot: bootstrapStateWithProof, - }); + return new Lightclient({...args, bootstrap}); } start(): void { @@ -239,12 +180,12 @@ export class Lightclient { } getHead(): phase0.BeaconBlockHeader { - return this.head.header; + return this.lightclientSpec.store.optimisticHeader; } /** Returns header since head may change during request */ async getHeadStateProof(paths: JsonPath[]): Promise<{proof: TreeOffsetProof; header: phase0.BeaconBlockHeader}> { - const header = this.head.header; + const header = this.getHead(); const stateId = toHexString(header.stateRoot); const res = await this.api.proof.getStateProof(stateId, paths); return { @@ -266,10 +207,9 @@ export class Lightclient { const count = toPeriodRng + 1 - fromPeriodRng; const updates = await this.api.lightclient.getUpdates(fromPeriodRng, count); for (const update of updates) { - const lightClientUpdate = update.data; - this.processSyncCommitteeUpdate(lightClientUpdate); - const headPeriod = computeSyncPeriodAtSlot(lightClientUpdate.attestedHeader.slot); - this.logger.debug(`processed sync update for period ${headPeriod}`); + this.processSyncCommitteeUpdate(update.data); + this.logger.debug("processed sync update", {slot: update.data.attestedHeader.slot}); + // Yield to the macro queue, verifying updates is somewhat expensive and we want responsiveness await new Promise((r) => setTimeout(r, 0)); } @@ -287,7 +227,7 @@ export class Lightclient { while (true) { const currentPeriod = computeSyncPeriodAtSlot(this.currentSlot); // Check if we have a sync committee for the current clock period - if (!this.syncCommitteeByPeriod.has(currentPeriod)) { + if (!this.lightclientSpec.store.syncCommittees.has(currentPeriod)) { // Stop head tracking if (this.status.code === RunStatusCode.started) { this.status.controller.abort(); @@ -295,7 +235,7 @@ export class Lightclient { // Go into sync mode this.status = {code: RunStatusCode.syncing}; - const headPeriod = computeSyncPeriodAtSlot(this.head.header.slot); + const headPeriod = computeSyncPeriodAtSlot(this.getHead().slot); this.logger.debug("Syncing", {lastPeriod: headPeriod, currentPeriod}); try { @@ -367,6 +307,8 @@ export class Lightclient { private onSSE = (event: routes.events.BeaconEvent): void => { try { + this.logger.debug("Received SSE event", {event: event.type}); + switch (event.type) { case routes.events.EventType.lightClientOptimisticUpdate: this.processOptimisticUpdate(event.message); @@ -392,78 +334,8 @@ 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: altair.LightClientOptimisticUpdate): void { - const {attestedHeader, syncAggregate} = headerUpdate; - - // Prevent registering updates for slots to far ahead - if (attestedHeader.slot > slotWithFutureTolerance(this.config, this.genesisTime, MAX_CLOCK_DISPARITY_SEC)) { - throw Error(`header.slot ${attestedHeader.slot} is too far in the future, currentSlot: ${this.currentSlot}`); - } - - const period = computeSyncPeriodAtSlot(attestedHeader.slot); - const syncCommittee = this.syncCommitteeByPeriod.get(period); - if (!syncCommittee) { - // TODO: Attempt to fetch committee update for period if it's before the current clock period - throw Error(`No syncCommittee for period ${period}`); - } - - const headerBlockRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(attestedHeader); - const headerBlockRootHex = toHexString(headerBlockRoot); - - assertValidSignedHeader(this.config, syncCommittee, syncAggregate, headerBlockRoot, attestedHeader.slot); - - // Valid header, check if has enough bits. - // Only accept headers that have at least half of the max participation seen in this period - // From spec https://github.com/ethereum/consensus-specs/pull/2746/files#diff-5e27a813772fdd4ded9b04dec7d7467747c469552cd422d57c1c91ea69453b7dR122 - // Take the max of current period and previous period - const currMaxParticipation = this.maxParticipationByPeriod.get(period) ?? 0; - const prevMaxParticipation = this.maxParticipationByPeriod.get(period - 1) ?? 0; - const maxParticipation = Math.max(currMaxParticipation, prevMaxParticipation); - const minSafeParticipation = Math.floor(maxParticipation / SAFETY_THRESHOLD_FACTOR); - - const participation = sumBits(syncAggregate.syncCommitteeBits); - if (participation < minSafeParticipation) { - // TODO: Not really an error, this can happen - throw Error(`syncAggregate has participation ${participation} less than safe minimum ${minSafeParticipation}`); - } - - // Maybe register new max participation - if (participation > maxParticipation) { - this.maxParticipationByPeriod.set(period, participation); - pruneSetToMax(this.maxParticipationByPeriod, MAX_STORED_PARTICIPATION); - } - - // Maybe update the head - if ( - // Advance head - attestedHeader.slot > this.head.header.slot || - // Replace same slot head - (attestedHeader.slot === this.head.header.slot && participation > this.head.participation) - ) { - // TODO: Do metrics for each case (advance vs replace same slot) - const prevHead = this.head; - this.head = {header: attestedHeader, participation, blockRoot: headerBlockRootHex}; - - // This is not an error, but a problematic network condition worth knowing about - if (attestedHeader.slot === prevHead.header.slot && prevHead.blockRoot !== headerBlockRootHex) { - this.logger.warn("Head update on same slot", { - prevHeadSlot: prevHead.header.slot, - prevHeadRoot: prevHead.blockRoot, - }); - } - this.logger.info("Head updated", { - slot: attestedHeader.slot, - root: headerBlockRootHex, - }); - - // Emit to consumers - this.emitter.emit(LightclientEvent.head, attestedHeader); - } else { - this.logger.debug("Received valid head update did not update head", { - currentHead: `${this.head.header.slot} ${this.head.blockRoot}`, - eventHead: `${attestedHeader.slot} ${headerBlockRootHex}`, - }); - } + private processOptimisticUpdate(optimisticUpdate: altair.LightClientOptimisticUpdate): void { + this.lightclientSpec.onOptimisticUpdate(this.currentSlotWithTolerance(), optimisticUpdate); } /** @@ -471,105 +343,14 @@ export class Lightclient { * This headerUpdate may update the head if there's enough participation. */ 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); - assertValidFinalityProof(finalizedUpdate); - - const {finalizedHeader, syncAggregate} = finalizedUpdate; - const finalizedBlockRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(finalizedHeader); - const participation = sumBits(syncAggregate.syncCommitteeBits); - // Maybe update the finalized - if ( - this.finalized === null || - // Advance head - finalizedHeader.slot > this.finalized.header.slot || - // Replace same slot head - (finalizedHeader.slot === this.finalized.header.slot && participation > this.head.participation) - ) { - // TODO: Do metrics for each case (advance vs replace same slot) - const prevFinalized = this.finalized; - const finalizedBlockRootHex = toHexString(finalizedBlockRoot); - - this.finalized = {header: finalizedHeader, participation, blockRoot: finalizedBlockRootHex}; - - // This is not an error, but a problematic network condition worth knowing about - if ( - prevFinalized && - finalizedHeader.slot === prevFinalized.header.slot && - prevFinalized.blockRoot !== finalizedBlockRootHex - ) { - this.logger.warn("Finalized update on same slot", { - prevHeadSlot: prevFinalized.header.slot, - prevHeadRoot: prevFinalized.blockRoot, - }); - } - this.logger.info("Finalized updated", { - slot: finalizedHeader.slot, - root: finalizedBlockRootHex, - }); - - // Emit to consumers - this.emitter.emit(LightclientEvent.finalized, finalizedHeader); - } else { - this.logger.debug("Received valid finalized update did not update finalized", { - currentHead: `${this.finalized.header.slot} ${this.finalized.blockRoot}`, - eventHead: `${finalizedHeader.slot} ${finalizedBlockRoot}`, - }); - } + this.lightclientSpec.onFinalityUpdate(this.currentSlotWithTolerance(), finalizedUpdate); } - /** - * Process SyncCommittee update, signed by a known previous SyncCommittee. - * SyncCommittee can be updated at any time, not strictly at the period borders. - * - * period 0 period 1 period 2 - * -|----------------|----------------|----------------|-> time - * | now - * - current_sync_committee: period 0 - * - known next_sync_committee, signed by current_sync_committee - */ private processSyncCommitteeUpdate(update: altair.LightClientUpdate): void { - // Prevent registering updates for slots too far in the future - const updateSlot = update.attestedHeader.slot; - if (updateSlot > slotWithFutureTolerance(this.config, this.genesisTime, MAX_CLOCK_DISPARITY_SEC)) { - throw Error(`updateSlot ${updateSlot} is too far in the future, currentSlot ${this.currentSlot}`); - } - - // Must not rollback periods, since the cache is bounded an older committee could evict the current committee - const updatePeriod = computeSyncPeriodAtSlot(updateSlot); - const minPeriod = Math.min(-Infinity, ...this.syncCommitteeByPeriod.keys()); - if (updatePeriod < minPeriod) { - throw Error(`update must not rollback existing committee at period ${minPeriod}`); - } - - const syncCommittee = this.syncCommitteeByPeriod.get(updatePeriod); - if (!syncCommittee) { - throw Error(`No SyncCommittee for period ${updatePeriod}`); - } - - assertValidLightClientUpdate(this.config, syncCommittee, update); - - // Store next_sync_committee keyed by next period. - // Multiple updates could be requested for the same period, only keep the SyncCommittee associated with the best - // update available, where best is decided by `isBetterUpdate()` - const nextPeriod = updatePeriod + 1; - const existingNextSyncCommittee = this.syncCommitteeByPeriod.get(nextPeriod); - const newNextSyncCommitteeStats: LightclientUpdateStats = { - isFinalized: !isEmptyHeader(update.finalizedHeader), - participation: sumBits(update.syncAggregate.syncCommitteeBits), - slot: updateSlot, - }; + this.lightclientSpec.onUpdate(this.currentSlotWithTolerance(), update); + } - if (!existingNextSyncCommittee || isBetterUpdate(existingNextSyncCommittee, newNextSyncCommitteeStats)) { - this.logger.info("Stored SyncCommittee", {nextPeriod, replacedPrevious: existingNextSyncCommittee != null}); - this.emitter.emit(LightclientEvent.committee, updatePeriod); - this.syncCommitteeByPeriod.set(nextPeriod, { - ...newNextSyncCommitteeStats, - ...deserializeSyncCommittee(update.nextSyncCommittee), - }); - pruneSetToMax(this.syncCommitteeByPeriod, MAX_STORED_SYNC_COMMITTEES); - // TODO: Metrics, updated syncCommittee - } + private currentSlotWithTolerance(): Slot { + return slotWithFutureTolerance(this.config, this.genesisTime, MAX_CLOCK_DISPARITY_SEC); } } diff --git a/packages/light-client/src/spec/index.ts b/packages/light-client/src/spec/index.ts new file mode 100644 index 000000000000..8484c60e68b3 --- /dev/null +++ b/packages/light-client/src/spec/index.ts @@ -0,0 +1,61 @@ +import {IBeaconConfig} from "@lodestar/config"; +import {UPDATE_TIMEOUT} from "@lodestar/params"; +import {altair, Slot} from "@lodestar/types"; +import {computeSyncPeriodAtSlot} from "../utils/index.js"; +import {getSyncCommitteeAtPeriod, processLightClientUpdate, ProcessUpdateOpts} from "./processLightClientUpdate.js"; +import {ILightClientStore, LightClientStore, LightClientStoreEvents} from "./store.js"; +import {ZERO_FINALITY_BRANCH, ZERO_HEADER, ZERO_NEXT_SYNC_COMMITTEE_BRANCH, ZERO_SYNC_COMMITTEE} from "./utils.js"; + +export {isBetterUpdate, toLightClientUpdateSummary, LightClientUpdateSummary} from "./isBetterUpdate.js"; + +export class LightclientSpec { + readonly store: ILightClientStore; + + constructor( + config: IBeaconConfig, + private readonly opts: ProcessUpdateOpts & LightClientStoreEvents, + bootstrap: altair.LightClientBootstrap + ) { + this.store = new LightClientStore(config, bootstrap, opts); + } + + onUpdate(currentSlot: Slot, update: altair.LightClientUpdate): void { + processLightClientUpdate(this.store, currentSlot, this.opts, update); + } + + onFinalityUpdate(currentSlot: Slot, finalityUpdate: altair.LightClientFinalityUpdate): void { + this.onUpdate(currentSlot, { + attestedHeader: finalityUpdate.attestedHeader, + nextSyncCommittee: ZERO_SYNC_COMMITTEE, + nextSyncCommitteeBranch: ZERO_NEXT_SYNC_COMMITTEE_BRANCH, + finalizedHeader: finalityUpdate.finalizedHeader, + finalityBranch: finalityUpdate.finalityBranch, + syncAggregate: finalityUpdate.syncAggregate, + signatureSlot: finalityUpdate.signatureSlot, + }); + } + + onOptimisticUpdate(currentSlot: Slot, optimisticUpdate: altair.LightClientOptimisticUpdate): void { + this.onUpdate(currentSlot, { + attestedHeader: optimisticUpdate.attestedHeader, + nextSyncCommittee: ZERO_SYNC_COMMITTEE, + nextSyncCommitteeBranch: ZERO_NEXT_SYNC_COMMITTEE_BRANCH, + finalizedHeader: ZERO_HEADER, + finalityBranch: ZERO_FINALITY_BRANCH, + syncAggregate: optimisticUpdate.syncAggregate, + signatureSlot: optimisticUpdate.signatureSlot, + }); + } + + forceUpdate(currentSlot: Slot): void { + for (const bestValidUpdate of this.store.bestValidUpdates.values()) { + if (currentSlot > bestValidUpdate.update.finalizedHeader.slot + UPDATE_TIMEOUT) { + const updatePeriod = computeSyncPeriodAtSlot(bestValidUpdate.update.signatureSlot); + // Simulate process_light_client_store_force_update() by forcing to apply a bestValidUpdate + // https://github.com/ethereum/consensus-specs/blob/a57e15636013eeba3610ff3ade41781dba1bb0cd/specs/altair/light-client/sync-protocol.md?plain=1#L394 + // Call for `updatePeriod + 1` to force the update at `update.signatureSlot` to be applied + getSyncCommitteeAtPeriod(this.store, updatePeriod + 1, this.opts); + } + } + } +} diff --git a/packages/light-client/src/spec/isBetterUpdate.ts b/packages/light-client/src/spec/isBetterUpdate.ts new file mode 100644 index 000000000000..62651c385f22 --- /dev/null +++ b/packages/light-client/src/spec/isBetterUpdate.ts @@ -0,0 +1,94 @@ +import {SYNC_COMMITTEE_SIZE} from "@lodestar/params"; +import {altair, Slot} from "@lodestar/types"; +import {computeSyncPeriodAtSlot} from "../utils/index.js"; +import {isFinalityUpdate, isSyncCommitteeUpdate, sumBits} from "./utils.js"; + +/** + * Wrapper type for `isBetterUpdate()` so we can apply its logic without requiring the full LightClientUpdate type. + */ +export type LightClientUpdateSummary = { + activeParticipants: number; + attestedHeaderSlot: Slot; + signatureSlot: Slot; + finalizedHeaderSlot: Slot; + /** `if update.next_sync_committee_branch != [Bytes32() for _ in range(floorlog2(NEXT_SYNC_COMMITTEE_INDEX))]` */ + isSyncCommitteeUpdate: boolean; + /** `if update.finality_branch != [Bytes32() for _ in range(floorlog2(FINALIZED_ROOT_INDEX))]` */ + isFinalityUpdate: boolean; +}; + +/** + * Returns the update with more bits. On ties, prevUpdate is the better + * + * https://github.com/ethereum/consensus-specs/blob/be3c774069e16e89145660be511c1b183056017e/specs/altair/light-client/sync-protocol.md#is_better_update + */ +export function isBetterUpdate(newUpdate: LightClientUpdateSummary, oldUpdate: LightClientUpdateSummary): boolean { + // Compare supermajority (> 2/3) sync committee participation + const newNumActiveParticipants = newUpdate.activeParticipants; + const oldNumActiveParticipants = oldUpdate.activeParticipants; + const newHasSupermajority = newNumActiveParticipants * 3 >= SYNC_COMMITTEE_SIZE * 2; + const oldHasSupermajority = oldNumActiveParticipants * 3 >= SYNC_COMMITTEE_SIZE * 2; + if (newHasSupermajority != oldHasSupermajority) { + return newHasSupermajority; + } + if (!newHasSupermajority && newNumActiveParticipants != oldNumActiveParticipants) { + return newNumActiveParticipants > oldNumActiveParticipants; + } + + // Compare presence of relevant sync committee + const newHasRelevantSyncCommittee = + newUpdate.isSyncCommitteeUpdate && + computeSyncPeriodAtSlot(newUpdate.attestedHeaderSlot) == computeSyncPeriodAtSlot(newUpdate.signatureSlot); + const oldHasRelevantSyncCommittee = + oldUpdate.isSyncCommitteeUpdate && + computeSyncPeriodAtSlot(oldUpdate.attestedHeaderSlot) == computeSyncPeriodAtSlot(oldUpdate.signatureSlot); + if (newHasRelevantSyncCommittee != oldHasRelevantSyncCommittee) { + return newHasRelevantSyncCommittee; + } + + // Compare indication of any finality + const newHasFinality = newUpdate.isFinalityUpdate; + const oldHasFinality = oldUpdate.isFinalityUpdate; + if (newHasFinality != oldHasFinality) { + return newHasFinality; + } + + // Compare sync committee finality + if (newHasFinality) { + const newHasSyncCommitteeFinality = + computeSyncPeriodAtSlot(newUpdate.finalizedHeaderSlot) == computeSyncPeriodAtSlot(newUpdate.attestedHeaderSlot); + const oldHasSyncCommitteeFinality = + computeSyncPeriodAtSlot(oldUpdate.finalizedHeaderSlot) == computeSyncPeriodAtSlot(oldUpdate.attestedHeaderSlot); + if (newHasSyncCommitteeFinality != oldHasSyncCommitteeFinality) { + return newHasSyncCommitteeFinality; + } + } + + // Tiebreaker 1: Sync committee participation beyond supermajority + if (newNumActiveParticipants != oldNumActiveParticipants) { + return newNumActiveParticipants > oldNumActiveParticipants; + } + + // Tiebreaker 2: Prefer older data (fewer changes to best) + if (newUpdate.attestedHeaderSlot != oldUpdate.attestedHeaderSlot) { + return newUpdate.attestedHeaderSlot < oldUpdate.attestedHeaderSlot; + } + return newUpdate.signatureSlot < oldUpdate.signatureSlot; +} + +export function isSafeLightClientUpdate(update: LightClientUpdateSummary): boolean { + return ( + update.activeParticipants * 3 >= SYNC_COMMITTEE_SIZE * 2 && update.isFinalityUpdate && update.isSyncCommitteeUpdate + ); +} + +export function toLightClientUpdateSummary(update: altair.LightClientUpdate): LightClientUpdateSummary { + return { + activeParticipants: sumBits(update.syncAggregate.syncCommitteeBits), + attestedHeaderSlot: update.attestedHeader.slot, + signatureSlot: update.signatureSlot, + finalizedHeaderSlot: update.finalizedHeader.slot, + isSyncCommitteeUpdate: isSyncCommitteeUpdate(update), + isFinalityUpdate: isFinalityUpdate(update), + }; +} diff --git a/packages/light-client/src/spec/processLightClientUpdate.ts b/packages/light-client/src/spec/processLightClientUpdate.ts new file mode 100644 index 000000000000..6e71604ba65a --- /dev/null +++ b/packages/light-client/src/spec/processLightClientUpdate.ts @@ -0,0 +1,119 @@ +import {SYNC_COMMITTEE_SIZE} from "@lodestar/params"; +import {altair, Slot, SyncPeriod} from "@lodestar/types"; +import {pruneSetToMax} from "@lodestar/utils"; +import {computeSyncPeriodAtSlot, deserializeSyncCommittee, sumBits} from "../utils/index.js"; +import {isBetterUpdate, LightClientUpdateSummary, toLightClientUpdateSummary} from "./isBetterUpdate.js"; +import {ILightClientStore, MAX_SYNC_PERIODS_CACHE, SyncCommitteeFast} from "./store.js"; +import {getSafetyThreshold, isSyncCommitteeUpdate} from "./utils.js"; +import {validateLightClientUpdate} from "./validateLightClientUpdate.js"; + +export interface ProcessUpdateOpts { + allowForcedUpdates?: boolean; + updateHeadersOnForcedUpdate?: boolean; +} + +export function processLightClientUpdate( + store: ILightClientStore, + currentSlot: Slot, + opts: ProcessUpdateOpts, + update: altair.LightClientUpdate +): void { + if (update.signatureSlot > currentSlot) { + throw Error(`update slot ${update.signatureSlot} must not be in the future, current slot ${currentSlot}`); + } + + const updateSignaturePeriod = computeSyncPeriodAtSlot(update.signatureSlot); + // TODO: Consider attempting to retrieve LightClientUpdate from transport if missing + // Note: store.getSyncCommitteeAtPeriod() may advance store + const syncCommittee = getSyncCommitteeAtPeriod(store, updateSignaturePeriod, opts); + + validateLightClientUpdate(store, update, syncCommittee); + + // Track the maximum number of active participants in the committee signatures + const syncCommitteeTrueBits = sumBits(update.syncAggregate.syncCommitteeBits); + store.setActiveParticipants(updateSignaturePeriod, syncCommitteeTrueBits); + + // Update the optimistic header + if ( + syncCommitteeTrueBits > getSafetyThreshold(store.getMaxActiveParticipants(updateSignaturePeriod)) && + update.attestedHeader.slot > store.optimisticHeader.slot + ) { + store.optimisticHeader = update.attestedHeader; + } + + // Update finalized header + if ( + syncCommitteeTrueBits * 3 >= SYNC_COMMITTEE_SIZE * 2 && + update.finalizedHeader.slot > store.finalizedHeader.slot + ) { + store.finalizedHeader = update.finalizedHeader; + if (store.finalizedHeader.slot > store.optimisticHeader.slot) { + store.optimisticHeader = store.finalizedHeader; + } + } + + if (isSyncCommitteeUpdate(update)) { + // Update the best update in case we have to force-update to it if the timeout elapses + const bestValidUpdate = store.bestValidUpdates.get(updateSignaturePeriod); + const updateSummary = toLightClientUpdateSummary(update); + if (!bestValidUpdate || isBetterUpdate(updateSummary, bestValidUpdate.summary)) { + store.bestValidUpdates.set(updateSignaturePeriod, {update, summary: updateSummary}); + pruneSetToMax(store.bestValidUpdates, MAX_SYNC_PERIODS_CACHE); + } + + // Note: defer update next sync committee to a future getSyncCommitteeAtPeriod() call + } +} + +export function getSyncCommitteeAtPeriod( + store: ILightClientStore, + period: SyncPeriod, + opts: ProcessUpdateOpts +): SyncCommitteeFast { + const syncCommittee = store.syncCommittees.get(period); + if (syncCommittee) { + return syncCommittee; + } + + const bestValidUpdate = store.bestValidUpdates.get(period - 1); + if (bestValidUpdate) { + if (isSafeLightClientUpdate(bestValidUpdate.summary) || opts.allowForcedUpdates) { + const {update} = bestValidUpdate; + const syncCommittee = deserializeSyncCommittee(update.nextSyncCommittee); + store.syncCommittees.set(period, syncCommittee); + pruneSetToMax(store.syncCommittees, MAX_SYNC_PERIODS_CACHE); + store.bestValidUpdates.delete(period - 1); + + if (opts.updateHeadersOnForcedUpdate) { + // From https://github.com/ethereum/consensus-specs/blob/a57e15636013eeba3610ff3ade41781dba1bb0cd/specs/altair/light-client/sync-protocol.md?plain=1#L403 + if (update.finalizedHeader.slot <= store.finalizedHeader.slot) { + update.finalizedHeader = update.attestedHeader; + } + + // From https://github.com/ethereum/consensus-specs/blob/a57e15636013eeba3610ff3ade41781dba1bb0cd/specs/altair/light-client/sync-protocol.md?plain=1#L374 + if (update.finalizedHeader.slot > store.finalizedHeader.slot) { + store.finalizedHeader = update.finalizedHeader; + } + if (store.finalizedHeader.slot > store.optimisticHeader.slot) { + store.optimisticHeader = store.finalizedHeader; + } + } + + return syncCommittee; + } + } + + const availableSyncCommittees = Array.from(store.syncCommittees.keys()); + const availableBestValidUpdates = Array.from(store.bestValidUpdates.keys()); + throw Error( + `No SyncCommittee for period ${period}` + + ` available syncCommittees ${JSON.stringify(availableSyncCommittees)}` + + ` available bestValidUpdates ${JSON.stringify(availableBestValidUpdates)}` + ); +} + +export function isSafeLightClientUpdate(update: LightClientUpdateSummary): boolean { + return ( + update.activeParticipants * 3 >= SYNC_COMMITTEE_SIZE * 2 && update.isFinalityUpdate && update.isSyncCommitteeUpdate + ); +} diff --git a/packages/light-client/src/spec/store.ts b/packages/light-client/src/spec/store.ts new file mode 100644 index 000000000000..769a239f503a --- /dev/null +++ b/packages/light-client/src/spec/store.ts @@ -0,0 +1,105 @@ +import type {PublicKey} from "@chainsafe/bls/types"; +import {IBeaconConfig} from "@lodestar/config"; +import {altair, phase0, SyncPeriod} from "@lodestar/types"; +import {computeSyncPeriodAtSlot, deserializeSyncCommittee} from "../utils/index.js"; +import {LightClientUpdateSummary} from "./isBetterUpdate.js"; + +export const MAX_SYNC_PERIODS_CACHE = 2; + +export interface ILightClientStore { + readonly config: IBeaconConfig; + + /** Map of trusted SyncCommittee to be used for sig validation */ + readonly syncCommittees: Map; + /** Map of best valid updates */ + readonly bestValidUpdates: Map; + + getMaxActiveParticipants(period: SyncPeriod): number; + setActiveParticipants(period: SyncPeriod, activeParticipants: number): void; + + // Header that is finalized + finalizedHeader: phase0.BeaconBlockHeader; + + // Most recent available reasonably-safe header + optimisticHeader: phase0.BeaconBlockHeader; +} + +export interface LightClientStoreEvents { + onSetFinalizedHeader?: (header: phase0.BeaconBlockHeader) => void; + onSetOptimisticHeader?: (header: phase0.BeaconBlockHeader) => void; +} + +export class LightClientStore implements ILightClientStore { + readonly syncCommittees = new Map(); + readonly bestValidUpdates = new Map(); + + private _finalizedHeader: phase0.BeaconBlockHeader; + private _optimisticHeader: phase0.BeaconBlockHeader; + + private readonly maxActiveParticipants = new Map(); + + constructor( + readonly config: IBeaconConfig, + bootstrap: altair.LightClientBootstrap, + private readonly events: LightClientStoreEvents + ) { + const bootstrapPeriod = computeSyncPeriodAtSlot(bootstrap.header.slot); + this.syncCommittees.set(bootstrapPeriod, deserializeSyncCommittee(bootstrap.currentSyncCommittee)); + this._finalizedHeader = bootstrap.header; + this._optimisticHeader = bootstrap.header; + } + + get finalizedHeader(): phase0.BeaconBlockHeader { + return this._finalizedHeader; + } + + set finalizedHeader(value: phase0.BeaconBlockHeader) { + this._finalizedHeader = value; + this.events.onSetFinalizedHeader?.(value); + } + + get optimisticHeader(): phase0.BeaconBlockHeader { + return this._optimisticHeader; + } + + set optimisticHeader(value: phase0.BeaconBlockHeader) { + this._optimisticHeader = value; + this.events.onSetOptimisticHeader?.(value); + } + + getMaxActiveParticipants(period: SyncPeriod): number { + const currMaxParticipants = this.maxActiveParticipants.get(period) ?? 0; + const prevMaxParticipants = this.maxActiveParticipants.get(period - 1) ?? 0; + + return Math.max(currMaxParticipants, prevMaxParticipants); + } + + setActiveParticipants(period: SyncPeriod, activeParticipants: number): void { + const maxActiveParticipants = this.maxActiveParticipants.get(period) ?? 0; + if (activeParticipants > maxActiveParticipants) { + this.maxActiveParticipants.set(period, activeParticipants); + } + + // Prune old entries + for (const key of this.maxActiveParticipants.keys()) { + if (key < period - MAX_SYNC_PERIODS_CACHE) { + this.maxActiveParticipants.delete(key); + } + } + } +} + +export type SyncCommitteeFast = { + pubkeys: PublicKey[]; + aggregatePubkey: PublicKey; +}; + +export type LightClientUpdateWithSummary = { + update: altair.LightClientUpdate; + summary: LightClientUpdateSummary; +}; + +// === storePeriod ? store.currentSyncCommittee : store.nextSyncCommittee; +// if (!syncCommittee) { +// throw Error(`syncCommittee not available for signature period ${updateSignaturePeriod}`); +// } diff --git a/packages/light-client/src/spec/utils.ts b/packages/light-client/src/spec/utils.ts new file mode 100644 index 000000000000..b9ec4a7ee7cf --- /dev/null +++ b/packages/light-client/src/spec/utils.ts @@ -0,0 +1,47 @@ +import {BitArray, byteArrayEquals} from "@chainsafe/ssz"; +import {FINALIZED_ROOT_DEPTH, NEXT_SYNC_COMMITTEE_DEPTH} from "@lodestar/params"; +import {altair, phase0, ssz} from "@lodestar/types"; + +export const GENESIS_SLOT = 0; +export const ZERO_HASH = new Uint8Array(32); +export const ZERO_PUBKEY = new Uint8Array(48); +export const ZERO_SYNC_COMMITTEE = ssz.altair.SyncCommittee.defaultValue(); +export const ZERO_NEXT_SYNC_COMMITTEE_BRANCH = Array.from({length: NEXT_SYNC_COMMITTEE_DEPTH}, () => ZERO_HASH); +export const ZERO_HEADER = ssz.phase0.BeaconBlockHeader.defaultValue(); +export const ZERO_FINALITY_BRANCH = Array.from({length: FINALIZED_ROOT_DEPTH}, () => ZERO_HASH); +/** From https://notes.ethereum.org/@vbuterin/extended_light_client_protocol#Optimistic-head-determining-function */ +const SAFETY_THRESHOLD_FACTOR = 2; + +export function sumBits(bits: BitArray): number { + return bits.getTrueBitIndexes().length; +} + +export function getSafetyThreshold(maxActiveParticipants: number): number { + return Math.floor(maxActiveParticipants / SAFETY_THRESHOLD_FACTOR); +} + +export function isSyncCommitteeUpdate(update: altair.LightClientUpdate): boolean { + return ( + // Fast return for when constructing full LightClientUpdate from partial updates + update.nextSyncCommitteeBranch !== ZERO_NEXT_SYNC_COMMITTEE_BRANCH && + update.nextSyncCommitteeBranch.some((branch) => !byteArrayEquals(branch, ZERO_HASH)) + ); +} + +export function isFinalityUpdate(update: altair.LightClientUpdate): boolean { + return ( + // Fast return for when constructing full LightClientUpdate from partial updates + update.finalityBranch !== ZERO_FINALITY_BRANCH && + update.finalityBranch.some((branch) => !byteArrayEquals(branch, ZERO_HASH)) + ); +} + +export function isZeroedHeader(header: phase0.BeaconBlockHeader): boolean { + // Fast return for when constructing full LightClientUpdate from partial updates + return header === ZERO_HEADER || byteArrayEquals(header.bodyRoot, ZERO_HASH); +} + +export function isZeroedSyncCommittee(syncCommittee: altair.SyncCommittee): boolean { + // Fast return for when constructing full LightClientUpdate from partial updates + return syncCommittee === ZERO_SYNC_COMMITTEE || byteArrayEquals(syncCommittee.pubkeys[0], ZERO_PUBKEY); +} diff --git a/packages/light-client/src/spec/validateLightClientBootstrap.ts b/packages/light-client/src/spec/validateLightClientBootstrap.ts new file mode 100644 index 000000000000..e4551b4098af --- /dev/null +++ b/packages/light-client/src/spec/validateLightClientBootstrap.ts @@ -0,0 +1,26 @@ +import {byteArrayEquals} from "@chainsafe/ssz"; +import {altair, Root, ssz} from "@lodestar/types"; +import {toHex} from "@lodestar/utils"; +import {isValidMerkleBranch} from "../utils/verifyMerkleBranch.js"; + +const CURRENT_SYNC_COMMITTEE_INDEX = 22; +const CURRENT_SYNC_COMMITTEE_DEPTH = 5; + +export function validateLightClientBootstrap(trustedBlockRoot: Root, bootstrap: altair.LightClientBootstrap): void { + const headerRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(bootstrap.header); + if (!byteArrayEquals(headerRoot, trustedBlockRoot)) { + throw Error(`bootstrap header root ${toHex(headerRoot)} != trusted root ${toHex(trustedBlockRoot)}`); + } + + if ( + !isValidMerkleBranch( + ssz.altair.SyncCommittee.hashTreeRoot(bootstrap.currentSyncCommittee), + bootstrap.currentSyncCommitteeBranch, + CURRENT_SYNC_COMMITTEE_DEPTH, + CURRENT_SYNC_COMMITTEE_INDEX, + bootstrap.header.stateRoot + ) + ) { + throw Error("Invalid currentSyncCommittee merkle branch"); + } +} diff --git a/packages/light-client/src/spec/validateLightClientUpdate.ts b/packages/light-client/src/spec/validateLightClientUpdate.ts new file mode 100644 index 000000000000..265135183c16 --- /dev/null +++ b/packages/light-client/src/spec/validateLightClientUpdate.ts @@ -0,0 +1,133 @@ +import {altair, Root, ssz} from "@lodestar/types"; +import bls from "@chainsafe/bls/switchable"; +import type {PublicKey, Signature} from "@chainsafe/bls/types"; +import { + FINALIZED_ROOT_INDEX, + FINALIZED_ROOT_DEPTH, + NEXT_SYNC_COMMITTEE_INDEX, + NEXT_SYNC_COMMITTEE_DEPTH, + MIN_SYNC_COMMITTEE_PARTICIPANTS, + DOMAIN_SYNC_COMMITTEE, + GENESIS_SLOT, +} from "@lodestar/params"; +import {getParticipantPubkeys, sumBits} from "../utils/utils.js"; +import {isValidMerkleBranch} from "../utils/index.js"; +import {SyncCommitteeFast} from "../types.js"; +import {isFinalityUpdate, isSyncCommitteeUpdate, isZeroedHeader, isZeroedSyncCommittee, ZERO_HASH} from "./utils.js"; +import {ILightClientStore} from "./store.js"; + +export function validateLightClientUpdate( + store: ILightClientStore, + update: altair.LightClientUpdate, + syncCommittee: SyncCommitteeFast +): void { + // Verify sync committee has sufficient participants + if (sumBits(update.syncAggregate.syncCommitteeBits) < MIN_SYNC_COMMITTEE_PARTICIPANTS) { + throw Error("Sync committee has not sufficient participants"); + } + + // Sanity check that slots are in correct order + if (update.signatureSlot <= update.attestedHeader.slot) { + throw Error( + `signature slot ${update.signatureSlot} must be after attested header slot ${update.attestedHeader.slot}` + ); + } + if (update.attestedHeader.slot < update.finalizedHeader.slot) { + throw Error( + `attested header slot ${update.signatureSlot} must be after finalized header slot ${update.finalizedHeader.slot}` + ); + } + + // Verify that the `finality_branch`, if present, confirms `finalized_header` + // to match the finalized checkpoint root saved in the state of `attested_header`. + // Note that the genesis finalized checkpoint root is represented as a zero hash. + if (!isFinalityUpdate(update)) { + if (!isZeroedHeader(update.finalizedHeader)) { + throw Error("finalizedHeader must be zero for non-finality update"); + } + } else { + let finalizedRoot: Root; + + if (update.finalizedHeader.slot == GENESIS_SLOT) { + if (!isZeroedHeader(update.finalizedHeader)) { + throw Error("finalizedHeader must be zero for not finality update"); + } + finalizedRoot = ZERO_HASH; + } else { + finalizedRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(update.finalizedHeader); + } + + if ( + !isValidMerkleBranch( + finalizedRoot, + update.finalityBranch, + FINALIZED_ROOT_DEPTH, + FINALIZED_ROOT_INDEX, + update.attestedHeader.stateRoot + ) + ) { + throw Error("Invalid finality header merkle branch"); + } + } + + // Verify that the `next_sync_committee`, if present, actually is the next sync committee saved in the + // state of the `attested_header` + if (!isSyncCommitteeUpdate(update)) { + if (!isZeroedSyncCommittee(update.nextSyncCommittee)) { + throw Error("nextSyncCommittee must be zero for non sync committee update"); + } + } else { + if ( + !isValidMerkleBranch( + ssz.altair.SyncCommittee.hashTreeRoot(update.nextSyncCommittee), + update.nextSyncCommitteeBranch, + NEXT_SYNC_COMMITTEE_DEPTH, + NEXT_SYNC_COMMITTEE_INDEX, + update.attestedHeader.stateRoot + ) + ) { + throw Error("Invalid next sync committee merkle branch"); + } + } + + // Verify sync committee aggregate signature + + const participantPubkeys = getParticipantPubkeys(syncCommittee.pubkeys, update.syncAggregate.syncCommitteeBits); + + const signingRoot = ssz.phase0.SigningData.hashTreeRoot({ + objectRoot: ssz.phase0.BeaconBlockHeader.hashTreeRoot(update.attestedHeader), + domain: store.config.getDomain(update.signatureSlot, DOMAIN_SYNC_COMMITTEE), + }); + + if (!isValidBlsAggregate(participantPubkeys, signingRoot, update.syncAggregate.syncCommitteeSignature)) { + throw Error("Invalid aggregate signature"); + } +} + +/** + * Same as BLS.verifyAggregate but with detailed error messages + */ +function isValidBlsAggregate(publicKeys: PublicKey[], message: Uint8Array, signature: Uint8Array): boolean { + let aggPubkey: PublicKey; + try { + aggPubkey = bls.PublicKey.aggregate(publicKeys); + } catch (e) { + (e as Error).message = `Error aggregating pubkeys: ${(e as Error).message}`; + throw e; + } + + let sig: Signature; + try { + sig = bls.Signature.fromBytes(signature, undefined, true); + } catch (e) { + (e as Error).message = `Error deserializing signature: ${(e as Error).message}`; + throw e; + } + + try { + return sig.verify(aggPubkey, message); + } catch (e) { + (e as Error).message = `Error verifying signature: ${(e as Error).message}`; + throw e; + } +} diff --git a/packages/light-client/src/utils/utils.ts b/packages/light-client/src/utils/utils.ts index 16405fc25f9d..e368879d0866 100644 --- a/packages/light-client/src/utils/utils.ts +++ b/packages/light-client/src/utils/utils.ts @@ -49,7 +49,7 @@ export function toBlockHeader(block: altair.BeaconBlock): BeaconBlockHeader { } function deserializePubkeys(pubkeys: altair.LightClientUpdate["nextSyncCommittee"]["pubkeys"]): PublicKey[] { - return Array.from(pubkeys).map((pk) => bls.PublicKey.fromBytes(pk)); + return pubkeys.map((pk) => bls.PublicKey.fromBytes(pk)); } function serializePubkeys(pubkeys: PublicKey[]): altair.LightClientUpdate["nextSyncCommittee"]["pubkeys"] { diff --git a/packages/light-client/test/unit/sync.node.test.ts b/packages/light-client/test/unit/sync.node.test.ts index 2731c13dd11b..55e3e05ac51e 100644 --- a/packages/light-client/test/unit/sync.node.test.ts +++ b/packages/light-client/test/unit/sync.node.test.ts @@ -21,6 +21,7 @@ import { } from "../utils/utils.js"; import {startServer, ServerOpts} from "../utils/server.js"; import {isNode} from "../../src/utils/utils.js"; +import {computeSyncPeriodAtSlot} from "../../src/utils/clock.js"; const SOME_HASH = Buffer.alloc(32, 0xff); @@ -92,13 +93,18 @@ describe("sync", () => { beaconApiUrl: `http://${opts.host}:${opts.port}`, genesisData: {genesisTime, genesisValidatorsRoot}, checkpointRoot: checkpointRoot, + opts: { + // Trigger `LightclientEvent.finalized` events for the Promise below + allowForcedUpdates: true, + updateHeadersOnForcedUpdate: true, + }, }); afterEachCbs.push(() => lightclient.stop()); // Sync periods to current await new Promise((resolve) => { - lightclient.emitter.on(LightclientEvent.committee, (updatePeriod) => { - if (updatePeriod === targetPeriod) { + lightclient.emitter.on(LightclientEvent.finalized, (header) => { + if (computeSyncPeriodAtSlot(header.slot) >= targetPeriod) { resolve(); } }); @@ -107,7 +113,7 @@ describe("sync", () => { // Wait for lightclient to subscribe to header updates while (!eventsServerApi.hasSubscriptions()) { - await new Promise((r) => setTimeout(r, 10)); + await new Promise((r) => setTimeout(r, 100)); } // Test fetching a proof @@ -152,6 +158,7 @@ describe("sync", () => { lightclientServerApi.latestHeadUpdate = headUpdate; eventsServerApi.emit({type: routes.events.EventType.lightClientOptimisticUpdate, message: headUpdate}); + testLogger.debug("Emitted EventType.lightClientOptimisticUpdate", {slot}); } });