@lodestar/light-client 1.35.0-dev.f80d2d52da → 1.35.0-dev.fcf8d024ea

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/lib/events.d.ts +1 -5
  2. package/lib/events.d.ts.map +1 -0
  3. package/lib/events.js.map +1 -1
  4. package/lib/index.d.ts +1 -1
  5. package/lib/index.d.ts.map +1 -0
  6. package/lib/index.js +10 -5
  7. package/lib/index.js.map +1 -1
  8. package/lib/spec/index.d.ts +1 -1
  9. package/lib/spec/index.d.ts.map +1 -0
  10. package/lib/spec/index.js +3 -0
  11. package/lib/spec/index.js.map +1 -1
  12. package/lib/spec/isBetterUpdate.d.ts.map +1 -0
  13. package/lib/spec/processLightClientUpdate.d.ts.map +1 -0
  14. package/lib/spec/store.d.ts.map +1 -0
  15. package/lib/spec/store.js +7 -3
  16. package/lib/spec/store.js.map +1 -1
  17. package/lib/spec/utils.d.ts.map +1 -0
  18. package/lib/spec/utils.js.map +1 -1
  19. package/lib/spec/validateLightClientBootstrap.d.ts.map +1 -0
  20. package/lib/spec/validateLightClientUpdate.d.ts.map +1 -0
  21. package/lib/transport/index.d.ts.map +1 -0
  22. package/lib/transport/interface.d.ts.map +1 -0
  23. package/lib/transport/rest.d.ts +1 -1
  24. package/lib/transport/rest.d.ts.map +1 -0
  25. package/lib/transport/rest.js +5 -4
  26. package/lib/transport/rest.js.map +1 -1
  27. package/lib/transport.d.ts.map +1 -0
  28. package/lib/types.d.ts.map +1 -0
  29. package/lib/utils/api.d.ts.map +1 -0
  30. package/lib/utils/chunkify.d.ts.map +1 -0
  31. package/lib/utils/clock.d.ts.map +1 -0
  32. package/lib/utils/domain.d.ts.map +1 -0
  33. package/lib/utils/index.d.ts.map +1 -0
  34. package/lib/utils/logger.d.ts.map +1 -0
  35. package/lib/utils/map.d.ts.map +1 -0
  36. package/lib/utils/normalizeMerkleBranch.d.ts.map +1 -0
  37. package/lib/utils/update.d.ts.map +1 -0
  38. package/lib/utils/utils.d.ts.map +1 -0
  39. package/lib/utils/verifyMerkleBranch.d.ts.map +1 -0
  40. package/lib/utils.d.ts.map +1 -0
  41. package/lib/validation.d.ts.map +1 -0
  42. package/package.json +16 -18
  43. package/src/events.ts +17 -0
  44. package/src/index.ts +340 -0
  45. package/src/spec/index.ts +71 -0
  46. package/src/spec/isBetterUpdate.ts +94 -0
  47. package/src/spec/processLightClientUpdate.ts +119 -0
  48. package/src/spec/store.ts +105 -0
  49. package/src/spec/utils.ts +266 -0
  50. package/src/spec/validateLightClientBootstrap.ts +41 -0
  51. package/src/spec/validateLightClientUpdate.ts +154 -0
  52. package/src/transport/index.ts +2 -0
  53. package/src/transport/interface.ts +37 -0
  54. package/src/transport/rest.ts +89 -0
  55. package/src/transport.ts +2 -0
  56. package/src/types.ts +20 -0
  57. package/src/utils/api.ts +19 -0
  58. package/src/utils/chunkify.ts +26 -0
  59. package/src/utils/clock.ts +45 -0
  60. package/src/utils/domain.ts +44 -0
  61. package/src/utils/index.ts +8 -0
  62. package/src/utils/logger.ts +29 -0
  63. package/src/utils/map.ts +20 -0
  64. package/src/utils/normalizeMerkleBranch.ts +15 -0
  65. package/src/utils/update.ts +30 -0
  66. package/src/utils/utils.ts +95 -0
  67. package/src/utils/verifyMerkleBranch.ts +29 -0
  68. package/src/utils.ts +2 -0
  69. package/src/validation.ts +201 -0
package/src/index.ts ADDED
@@ -0,0 +1,340 @@
1
+ import mitt from "mitt";
2
+ import {BeaconConfig, ChainForkConfig, createBeaconConfig} from "@lodestar/config";
3
+ import {EPOCHS_PER_SYNC_COMMITTEE_PERIOD} from "@lodestar/params";
4
+ import {
5
+ LightClientBootstrap,
6
+ LightClientFinalityUpdate,
7
+ LightClientHeader,
8
+ LightClientOptimisticUpdate,
9
+ LightClientUpdate,
10
+ RootHex,
11
+ Slot,
12
+ SyncPeriod,
13
+ phase0,
14
+ } from "@lodestar/types";
15
+ import {fromHex, isErrorAborted, sleep, toRootHex} from "@lodestar/utils";
16
+ import {LightclientEmitter, LightclientEvent} from "./events.js";
17
+ import {LightclientSpec} from "./spec/index.js";
18
+ import {ProcessUpdateOpts} from "./spec/processLightClientUpdate.js";
19
+ import {validateLightClientBootstrap} from "./spec/validateLightClientBootstrap.js";
20
+ import {LightClientTransport} from "./transport/interface.js";
21
+ import {chunkifyInclusiveRange} from "./utils/chunkify.js";
22
+ import {
23
+ computeEpochAtSlot,
24
+ computeSyncPeriodAtEpoch,
25
+ computeSyncPeriodAtSlot,
26
+ getCurrentSlot,
27
+ slotWithFutureTolerance,
28
+ timeUntilNextEpoch,
29
+ } from "./utils/clock.js";
30
+ import {ILcLogger, getConsoleLogger} from "./utils/logger.js";
31
+
32
+ // Re-export types
33
+ export {LightclientEvent} from "./events.js";
34
+ export {upgradeLightClientFinalityUpdate, upgradeLightClientOptimisticUpdate} from "./spec/utils.js";
35
+ export type {SyncCommitteeFast} from "./types.js";
36
+
37
+ export type GenesisData = {
38
+ genesisTime: number;
39
+ genesisValidatorsRoot: RootHex | Uint8Array;
40
+ };
41
+
42
+ export type LightclientOpts = ProcessUpdateOpts;
43
+
44
+ export type LightclientInitArgs = {
45
+ config: ChainForkConfig;
46
+ logger?: ILcLogger;
47
+ opts?: LightclientOpts;
48
+ genesisData: GenesisData;
49
+ transport: LightClientTransport;
50
+ bootstrap: LightClientBootstrap;
51
+ };
52
+
53
+ /** Provides some protection against a server client sending header updates too far away in the future */
54
+ const MAX_CLOCK_DISPARITY_SEC = 10;
55
+ /** Prevent responses that are too big and get truncated. No specific reasoning for 32 */
56
+ const MAX_PERIODS_PER_REQUEST = 32;
57
+ /** For mainnet preset 8 epochs, for minimal preset `EPOCHS_PER_SYNC_COMMITTEE_PERIOD / 2` */
58
+ const LOOKAHEAD_EPOCHS_COMMITTEE_SYNC = Math.min(8, Math.ceil(EPOCHS_PER_SYNC_COMMITTEE_PERIOD / 2));
59
+ /** Prevent infinite loops caused by sync errors */
60
+ const ON_ERROR_RETRY_MS = 1000;
61
+
62
+ // TODO: Customize with option
63
+ const ALLOW_FORCED_UPDATES = true;
64
+
65
+ export enum RunStatusCode {
66
+ uninitialized,
67
+ started,
68
+ syncing,
69
+ stopped,
70
+ }
71
+ type RunStatus =
72
+ | {code: RunStatusCode.uninitialized}
73
+ | {code: RunStatusCode.started; controller: AbortController}
74
+ | {code: RunStatusCode.syncing}
75
+ | {code: RunStatusCode.stopped};
76
+
77
+ /**
78
+ * Server-based Lightclient. Current architecture diverges from the spec's proposed updated splitting them into:
79
+ * - Sync period updates: To advance to the next sync committee
80
+ * - Header updates: To get a more recent header signed by a known sync committee
81
+ *
82
+ * To stay synced to the current sync period it needs:
83
+ * - GET lightclient/committee_updates at least once per period.
84
+ *
85
+ * To get continuous header updates:
86
+ * - subscribe to SSE type lightclient_update
87
+ *
88
+ * To initialize, it needs:
89
+ * - GenesisData: To initialize the clock and verify signatures
90
+ * - For known networks it's hardcoded in the source
91
+ * - For unknown networks it can be provided by the user with a manual input
92
+ * - For unknown test networks it can be queried from a trusted node at GET beacon/genesis
93
+ * - `beaconApiUrl`: To connect to a trustless beacon node
94
+ * - `LightclientStore`: To have an initial trusted SyncCommittee to start the sync
95
+ * - For new lightclient instances, it can be queries from a trustless node at GET lightclient/bootstrap
96
+ * - For existing lightclient instances, it should be retrieved from storage
97
+ *
98
+ * When to trigger a committee update sync:
99
+ *
100
+ * period 0 period 1 period 2
101
+ * -|----------------|----------------|----------------|-> time
102
+ * | now
103
+ * - active current_sync_committee
104
+ * - known next_sync_committee, signed by current_sync_committee
105
+ *
106
+ * - No need to query for period 0 next_sync_committee until the end of period 0
107
+ * - During most of period 0, current_sync_committee known, next_sync_committee unknown
108
+ * - At the end of period 0, get a sync committee update, and populate period 1's committee
109
+ *
110
+ * syncCommittees: Map<SyncPeriod, SyncCommittee>, limited to max of 2 items
111
+ */
112
+ export class Lightclient {
113
+ readonly emitter: LightclientEmitter = mitt();
114
+ readonly config: BeaconConfig;
115
+ readonly logger: ILcLogger;
116
+ readonly genesisValidatorsRoot: Uint8Array;
117
+ readonly genesisTime: number;
118
+ private readonly transport: LightClientTransport;
119
+
120
+ private readonly lightclientSpec: LightclientSpec;
121
+
122
+ private runStatus: RunStatus = {code: RunStatusCode.stopped};
123
+
124
+ constructor({config, logger, genesisData, bootstrap, transport}: LightclientInitArgs) {
125
+ this.genesisTime = genesisData.genesisTime;
126
+ this.genesisValidatorsRoot =
127
+ typeof genesisData.genesisValidatorsRoot === "string"
128
+ ? fromHex(genesisData.genesisValidatorsRoot)
129
+ : genesisData.genesisValidatorsRoot;
130
+
131
+ this.config = createBeaconConfig(config, this.genesisValidatorsRoot);
132
+ this.logger = logger ?? getConsoleLogger();
133
+ this.transport = transport;
134
+ this.runStatus = {code: RunStatusCode.uninitialized};
135
+
136
+ this.lightclientSpec = new LightclientSpec(
137
+ this.config,
138
+ {
139
+ allowForcedUpdates: ALLOW_FORCED_UPDATES,
140
+ onSetFinalizedHeader: (header) => {
141
+ this.emitter.emit(LightclientEvent.lightClientFinalityHeader, header);
142
+ this.logger.debug("Updated state.finalizedHeader", {slot: header.beacon.slot});
143
+ },
144
+ onSetOptimisticHeader: (header) => {
145
+ this.emitter.emit(LightclientEvent.lightClientOptimisticHeader, header);
146
+ this.logger.debug("Updated state.optimisticHeader", {slot: header.beacon.slot});
147
+ },
148
+ },
149
+ bootstrap
150
+ );
151
+ }
152
+
153
+ get status(): RunStatusCode {
154
+ return this.runStatus.code;
155
+ }
156
+
157
+ // Embed lightweight clock. The epoch cycles are handled with `this.runLoop()`
158
+ get currentSlot(): number {
159
+ return getCurrentSlot(this.config, this.genesisTime);
160
+ }
161
+
162
+ static async initializeFromCheckpointRoot(
163
+ args: Omit<LightclientInitArgs, "bootstrap"> & {
164
+ checkpointRoot: phase0.Checkpoint["root"];
165
+ }
166
+ ): Promise<Lightclient> {
167
+ const {transport, checkpointRoot} = args;
168
+
169
+ // Fetch bootstrap state with proof at the trusted block root
170
+ const {data: bootstrap} = await transport.getBootstrap(toRootHex(checkpointRoot));
171
+
172
+ validateLightClientBootstrap(args.config, checkpointRoot, bootstrap);
173
+
174
+ return new Lightclient({...args, bootstrap});
175
+ }
176
+
177
+ /**
178
+ * @returns a `Promise` that will resolve once `runStatus` equals `RunStatusCode.started`
179
+ */
180
+ start(): Promise<void> {
181
+ const startPromise = new Promise<void>((resolve) => {
182
+ const resolveAndStopListening = (status: RunStatusCode): void => {
183
+ if (status === RunStatusCode.started) {
184
+ this.emitter.off(LightclientEvent.statusChange, resolveAndStopListening);
185
+ resolve();
186
+ }
187
+ };
188
+ this.emitter.on(LightclientEvent.statusChange, resolveAndStopListening);
189
+
190
+ // If already started, resolve immediately
191
+ // Checking after the event registration to remove potential for race conditions
192
+ resolveAndStopListening(this.runStatus.code);
193
+ });
194
+
195
+ // Do not block the event loop
196
+ void this.runLoop();
197
+
198
+ return startPromise;
199
+ }
200
+
201
+ stop(): void {
202
+ if (this.runStatus.code !== RunStatusCode.started) return;
203
+
204
+ this.runStatus.controller.abort();
205
+ this.updateRunStatus({code: RunStatusCode.stopped});
206
+ }
207
+
208
+ getHead(): LightClientHeader {
209
+ return this.lightclientSpec.store.optimisticHeader;
210
+ }
211
+
212
+ getFinalized(): LightClientHeader {
213
+ return this.lightclientSpec.store.finalizedHeader;
214
+ }
215
+
216
+ async sync(fromPeriod: SyncPeriod, toPeriod: SyncPeriod): Promise<void> {
217
+ const periodRanges = chunkifyInclusiveRange(fromPeriod, toPeriod, MAX_PERIODS_PER_REQUEST);
218
+
219
+ for (const [fromPeriodRng, toPeriodRng] of periodRanges) {
220
+ const count = toPeriodRng + 1 - fromPeriodRng;
221
+ const updates = await this.transport.getUpdates(fromPeriodRng, count);
222
+ for (const update of updates) {
223
+ this.processSyncCommitteeUpdate(update.data);
224
+ this.logger.debug("processed sync update", {slot: update.data.attestedHeader.beacon.slot});
225
+
226
+ // Yield to the macro queue, verifying updates is somewhat expensive and we want responsiveness
227
+ await new Promise((r) => setTimeout(r, 0));
228
+ }
229
+ }
230
+ }
231
+
232
+ private async runLoop(): Promise<void> {
233
+ while (true) {
234
+ const currentPeriod = computeSyncPeriodAtSlot(this.currentSlot);
235
+ // Check if we have a sync committee for the current clock period
236
+ if (!this.lightclientSpec.store.syncCommittees.has(currentPeriod)) {
237
+ // Stop head tracking
238
+ if (this.runStatus.code === RunStatusCode.started) {
239
+ this.runStatus.controller.abort();
240
+ }
241
+
242
+ // Go into sync mode
243
+ this.updateRunStatus({code: RunStatusCode.syncing});
244
+ const headPeriod = computeSyncPeriodAtSlot(this.getHead().beacon.slot);
245
+ this.logger.debug("Syncing", {lastPeriod: headPeriod, currentPeriod});
246
+
247
+ try {
248
+ await this.sync(headPeriod, currentPeriod);
249
+ this.logger.debug("Synced", {currentPeriod});
250
+ } catch (e) {
251
+ this.logger.error("Error sync", {}, e as Error);
252
+
253
+ // Retry in 1 second
254
+ await new Promise((r) => setTimeout(r, ON_ERROR_RETRY_MS));
255
+ continue;
256
+ }
257
+ }
258
+
259
+ // After successfully syncing, track head if not already
260
+ if (this.runStatus.code !== RunStatusCode.started) {
261
+ const controller = new AbortController();
262
+ this.updateRunStatus({code: RunStatusCode.started, controller});
263
+ this.logger.debug("Started tracking the head");
264
+
265
+ // Fetch latest optimistic head to prevent a potential 12 seconds lag between syncing and getting the first head,
266
+ // Don't retry, this is a non-critical UX improvement
267
+ try {
268
+ const update = await this.transport.getOptimisticUpdate();
269
+ this.processOptimisticUpdate(update.data);
270
+ } catch (e) {
271
+ this.logger.error("Error fetching getLatestHeadUpdate", {currentPeriod}, e as Error);
272
+ }
273
+
274
+ this.transport.onOptimisticUpdate(this.processOptimisticUpdate.bind(this));
275
+ this.transport.onFinalityUpdate(this.processFinalizedUpdate.bind(this));
276
+ }
277
+
278
+ // When close to the end of a sync period poll for sync committee updates
279
+ // Limit lookahead in case EPOCHS_PER_SYNC_COMMITTEE_PERIOD is configured to be very short
280
+
281
+ const currentEpoch = computeEpochAtSlot(this.currentSlot);
282
+ const epochsIntoPeriod = currentEpoch % EPOCHS_PER_SYNC_COMMITTEE_PERIOD;
283
+ // Start fetching updates with some lookahead
284
+ if (EPOCHS_PER_SYNC_COMMITTEE_PERIOD - epochsIntoPeriod <= LOOKAHEAD_EPOCHS_COMMITTEE_SYNC) {
285
+ const period = computeSyncPeriodAtEpoch(currentEpoch);
286
+ try {
287
+ await this.sync(period, period);
288
+ } catch (e) {
289
+ this.logger.error("Error re-syncing period", {period}, e as Error);
290
+ }
291
+ }
292
+
293
+ // Wait for the next epoch
294
+ try {
295
+ const runStatus = this.runStatus as {code: RunStatusCode.started; controller: AbortController}; // At this point, client is started
296
+ await sleep(timeUntilNextEpoch(this.config, this.genesisTime), runStatus.controller.signal);
297
+ } catch (e) {
298
+ if (isErrorAborted(e)) {
299
+ return;
300
+ }
301
+ throw e;
302
+ }
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Processes new optimistic header updates in only known synced sync periods.
308
+ * This headerUpdate may update the head if there's enough participation.
309
+ */
310
+ private processOptimisticUpdate(optimisticUpdate: LightClientOptimisticUpdate): void {
311
+ this.lightclientSpec.onOptimisticUpdate(this.currentSlotWithTolerance(), optimisticUpdate);
312
+ }
313
+
314
+ /**
315
+ * Processes new header updates in only known synced sync periods.
316
+ * This headerUpdate may update the head if there's enough participation.
317
+ */
318
+ private processFinalizedUpdate(finalizedUpdate: LightClientFinalityUpdate): void {
319
+ this.lightclientSpec.onFinalityUpdate(this.currentSlotWithTolerance(), finalizedUpdate);
320
+ }
321
+
322
+ private processSyncCommitteeUpdate(update: LightClientUpdate): void {
323
+ this.lightclientSpec.onUpdate(this.currentSlotWithTolerance(), update);
324
+ }
325
+
326
+ private currentSlotWithTolerance(): Slot {
327
+ return slotWithFutureTolerance(this.config, this.genesisTime, MAX_CLOCK_DISPARITY_SEC);
328
+ }
329
+
330
+ private updateRunStatus(runStatus: RunStatus): void {
331
+ this.runStatus = runStatus;
332
+ this.emitter.emit(LightclientEvent.statusChange, this.runStatus.code);
333
+ }
334
+ }
335
+
336
+ import * as transport from "./transport.js";
337
+ // To export these name spaces to the bundle JS
338
+ import * as utils from "./utils.js";
339
+ import * as validation from "./validation.js";
340
+ export {utils, validation, transport};
@@ -0,0 +1,71 @@
1
+ import {BeaconConfig} from "@lodestar/config";
2
+ import {UPDATE_TIMEOUT} from "@lodestar/params";
3
+ import {
4
+ LightClientBootstrap,
5
+ LightClientFinalityUpdate,
6
+ LightClientOptimisticUpdate,
7
+ LightClientUpdate,
8
+ Slot,
9
+ } from "@lodestar/types";
10
+ import {computeSyncPeriodAtSlot} from "../utils/index.js";
11
+ import {ProcessUpdateOpts, getSyncCommitteeAtPeriod, processLightClientUpdate} from "./processLightClientUpdate.js";
12
+ import {ILightClientStore, LightClientStore, LightClientStoreEvents} from "./store.js";
13
+ import {ZERO_HEADER, ZERO_SYNC_COMMITTEE, getZeroFinalityBranch, getZeroSyncCommitteeBranch} from "./utils.js";
14
+
15
+ export type {LightClientUpdateSummary} from "./isBetterUpdate.js";
16
+ export {isBetterUpdate, toLightClientUpdateSummary} from "./isBetterUpdate.js";
17
+ export {upgradeLightClientHeader} from "./utils.js";
18
+
19
+ export class LightclientSpec {
20
+ readonly store: ILightClientStore;
21
+ readonly config: BeaconConfig;
22
+
23
+ constructor(
24
+ config: BeaconConfig,
25
+ private readonly opts: ProcessUpdateOpts & LightClientStoreEvents,
26
+ bootstrap: LightClientBootstrap
27
+ ) {
28
+ this.store = new LightClientStore(config, bootstrap, opts);
29
+ this.config = config;
30
+ }
31
+
32
+ onUpdate(currentSlot: Slot, update: LightClientUpdate): void {
33
+ processLightClientUpdate(this.config, this.store, currentSlot, this.opts, update);
34
+ }
35
+
36
+ onFinalityUpdate(currentSlot: Slot, finalityUpdate: LightClientFinalityUpdate): void {
37
+ this.onUpdate(currentSlot, {
38
+ attestedHeader: finalityUpdate.attestedHeader,
39
+ nextSyncCommittee: ZERO_SYNC_COMMITTEE,
40
+ nextSyncCommitteeBranch: getZeroSyncCommitteeBranch(this.config.getForkName(finalityUpdate.signatureSlot)),
41
+ finalizedHeader: finalityUpdate.finalizedHeader,
42
+ finalityBranch: finalityUpdate.finalityBranch,
43
+ syncAggregate: finalityUpdate.syncAggregate,
44
+ signatureSlot: finalityUpdate.signatureSlot,
45
+ });
46
+ }
47
+
48
+ onOptimisticUpdate(currentSlot: Slot, optimisticUpdate: LightClientOptimisticUpdate): void {
49
+ this.onUpdate(currentSlot, {
50
+ attestedHeader: optimisticUpdate.attestedHeader,
51
+ nextSyncCommittee: ZERO_SYNC_COMMITTEE,
52
+ nextSyncCommitteeBranch: getZeroSyncCommitteeBranch(this.config.getForkName(optimisticUpdate.signatureSlot)),
53
+ finalizedHeader: {beacon: ZERO_HEADER},
54
+ finalityBranch: getZeroFinalityBranch(this.config.getForkName(optimisticUpdate.signatureSlot)),
55
+ syncAggregate: optimisticUpdate.syncAggregate,
56
+ signatureSlot: optimisticUpdate.signatureSlot,
57
+ });
58
+ }
59
+
60
+ forceUpdate(currentSlot: Slot): void {
61
+ for (const bestValidUpdate of this.store.bestValidUpdates.values()) {
62
+ if (currentSlot > bestValidUpdate.update.finalizedHeader.beacon.slot + UPDATE_TIMEOUT) {
63
+ const updatePeriod = computeSyncPeriodAtSlot(bestValidUpdate.update.signatureSlot);
64
+ // Simulate process_light_client_store_force_update() by forcing to apply a bestValidUpdate
65
+ // https://github.com/ethereum/consensus-specs/blob/a57e15636013eeba3610ff3ade41781dba1bb0cd/specs/altair/light-client/sync-protocol.md?plain=1#L394
66
+ // Call for `updatePeriod + 1` to force the update at `update.signatureSlot` to be applied
67
+ getSyncCommitteeAtPeriod(this.store, updatePeriod + 1, this.opts);
68
+ }
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,94 @@
1
+ import {SYNC_COMMITTEE_SIZE} from "@lodestar/params";
2
+ import {LightClientUpdate, Slot} from "@lodestar/types";
3
+ import {computeSyncPeriodAtSlot} from "../utils/index.js";
4
+ import {isFinalityUpdate, isSyncCommitteeUpdate, sumBits} from "./utils.js";
5
+
6
+ /**
7
+ * Wrapper type for `isBetterUpdate()` so we can apply its logic without requiring the full LightClientUpdate type.
8
+ */
9
+ export type LightClientUpdateSummary = {
10
+ activeParticipants: number;
11
+ attestedHeaderSlot: Slot;
12
+ signatureSlot: Slot;
13
+ finalizedHeaderSlot: Slot;
14
+ /** `if update.next_sync_committee_branch != [Bytes32() for _ in range(floorlog2(NEXT_SYNC_COMMITTEE_INDEX))]` */
15
+ isSyncCommitteeUpdate: boolean;
16
+ /** `if update.finality_branch != [Bytes32() for _ in range(floorlog2(FINALIZED_ROOT_INDEX))]` */
17
+ isFinalityUpdate: boolean;
18
+ };
19
+
20
+ /**
21
+ * Returns the update with more bits. On ties, prevUpdate is the better
22
+ *
23
+ * https://github.com/ethereum/consensus-specs/blob/be3c774069e16e89145660be511c1b183056017e/specs/altair/light-client/sync-protocol.md#is_better_update
24
+ */
25
+ export function isBetterUpdate(newUpdate: LightClientUpdateSummary, oldUpdate: LightClientUpdateSummary): boolean {
26
+ // Compare supermajority (> 2/3) sync committee participation
27
+ const newNumActiveParticipants = newUpdate.activeParticipants;
28
+ const oldNumActiveParticipants = oldUpdate.activeParticipants;
29
+ const newHasSupermajority = newNumActiveParticipants * 3 >= SYNC_COMMITTEE_SIZE * 2;
30
+ const oldHasSupermajority = oldNumActiveParticipants * 3 >= SYNC_COMMITTEE_SIZE * 2;
31
+ if (newHasSupermajority !== oldHasSupermajority) {
32
+ return newHasSupermajority;
33
+ }
34
+ if (!newHasSupermajority && newNumActiveParticipants !== oldNumActiveParticipants) {
35
+ return newNumActiveParticipants > oldNumActiveParticipants;
36
+ }
37
+
38
+ // Compare presence of relevant sync committee
39
+ const newHasRelevantSyncCommittee =
40
+ newUpdate.isSyncCommitteeUpdate &&
41
+ computeSyncPeriodAtSlot(newUpdate.attestedHeaderSlot) === computeSyncPeriodAtSlot(newUpdate.signatureSlot);
42
+ const oldHasRelevantSyncCommittee =
43
+ oldUpdate.isSyncCommitteeUpdate &&
44
+ computeSyncPeriodAtSlot(oldUpdate.attestedHeaderSlot) === computeSyncPeriodAtSlot(oldUpdate.signatureSlot);
45
+ if (newHasRelevantSyncCommittee !== oldHasRelevantSyncCommittee) {
46
+ return newHasRelevantSyncCommittee;
47
+ }
48
+
49
+ // Compare indication of any finality
50
+ const newHasFinality = newUpdate.isFinalityUpdate;
51
+ const oldHasFinality = oldUpdate.isFinalityUpdate;
52
+ if (newHasFinality !== oldHasFinality) {
53
+ return newHasFinality;
54
+ }
55
+
56
+ // Compare sync committee finality
57
+ if (newHasFinality) {
58
+ const newHasSyncCommitteeFinality =
59
+ computeSyncPeriodAtSlot(newUpdate.finalizedHeaderSlot) === computeSyncPeriodAtSlot(newUpdate.attestedHeaderSlot);
60
+ const oldHasSyncCommitteeFinality =
61
+ computeSyncPeriodAtSlot(oldUpdate.finalizedHeaderSlot) === computeSyncPeriodAtSlot(oldUpdate.attestedHeaderSlot);
62
+ if (newHasSyncCommitteeFinality !== oldHasSyncCommitteeFinality) {
63
+ return newHasSyncCommitteeFinality;
64
+ }
65
+ }
66
+
67
+ // Tiebreaker 1: Sync committee participation beyond supermajority
68
+ if (newNumActiveParticipants !== oldNumActiveParticipants) {
69
+ return newNumActiveParticipants > oldNumActiveParticipants;
70
+ }
71
+
72
+ // Tiebreaker 2: Prefer older data (fewer changes to best)
73
+ if (newUpdate.attestedHeaderSlot !== oldUpdate.attestedHeaderSlot) {
74
+ return newUpdate.attestedHeaderSlot < oldUpdate.attestedHeaderSlot;
75
+ }
76
+ return newUpdate.signatureSlot < oldUpdate.signatureSlot;
77
+ }
78
+
79
+ export function isSafeLightClientUpdate(update: LightClientUpdateSummary): boolean {
80
+ return (
81
+ update.activeParticipants * 3 >= SYNC_COMMITTEE_SIZE * 2 && update.isFinalityUpdate && update.isSyncCommitteeUpdate
82
+ );
83
+ }
84
+
85
+ export function toLightClientUpdateSummary(update: LightClientUpdate): LightClientUpdateSummary {
86
+ return {
87
+ activeParticipants: sumBits(update.syncAggregate.syncCommitteeBits),
88
+ attestedHeaderSlot: update.attestedHeader.beacon.slot,
89
+ signatureSlot: update.signatureSlot,
90
+ finalizedHeaderSlot: update.finalizedHeader.beacon.slot,
91
+ isSyncCommitteeUpdate: isSyncCommitteeUpdate(update),
92
+ isFinalityUpdate: isFinalityUpdate(update),
93
+ };
94
+ }
@@ -0,0 +1,119 @@
1
+ import {ChainForkConfig} from "@lodestar/config";
2
+ import {SYNC_COMMITTEE_SIZE} from "@lodestar/params";
3
+ import {LightClientUpdate, Slot, SyncPeriod} from "@lodestar/types";
4
+ import {pruneSetToMax} from "@lodestar/utils";
5
+ import {computeSyncPeriodAtSlot, deserializeSyncCommittee, sumBits} from "../utils/index.js";
6
+ import {LightClientUpdateSummary, isBetterUpdate, toLightClientUpdateSummary} from "./isBetterUpdate.js";
7
+ import {ILightClientStore, MAX_SYNC_PERIODS_CACHE, SyncCommitteeFast} from "./store.js";
8
+ import {getSafetyThreshold, isSyncCommitteeUpdate} from "./utils.js";
9
+ import {validateLightClientUpdate} from "./validateLightClientUpdate.js";
10
+
11
+ export interface ProcessUpdateOpts {
12
+ allowForcedUpdates?: boolean;
13
+ updateHeadersOnForcedUpdate?: boolean;
14
+ }
15
+
16
+ export function processLightClientUpdate(
17
+ config: ChainForkConfig,
18
+ store: ILightClientStore,
19
+ currentSlot: Slot,
20
+ opts: ProcessUpdateOpts,
21
+ update: LightClientUpdate
22
+ ): void {
23
+ if (update.signatureSlot > currentSlot) {
24
+ throw Error(`update slot ${update.signatureSlot} must not be in the future, current slot ${currentSlot}`);
25
+ }
26
+
27
+ const updateSignaturePeriod = computeSyncPeriodAtSlot(update.signatureSlot);
28
+ // TODO: Consider attempting to retrieve LightClientUpdate from transport if missing
29
+ // Note: store.getSyncCommitteeAtPeriod() may advance store
30
+ const syncCommittee = getSyncCommitteeAtPeriod(store, updateSignaturePeriod, opts);
31
+
32
+ validateLightClientUpdate(config, store, update, syncCommittee);
33
+
34
+ // Track the maximum number of active participants in the committee signatures
35
+ const syncCommitteeTrueBits = sumBits(update.syncAggregate.syncCommitteeBits);
36
+ store.setActiveParticipants(updateSignaturePeriod, syncCommitteeTrueBits);
37
+
38
+ // Update the optimistic header
39
+ if (
40
+ syncCommitteeTrueBits > getSafetyThreshold(store.getMaxActiveParticipants(updateSignaturePeriod)) &&
41
+ update.attestedHeader.beacon.slot > store.optimisticHeader.beacon.slot
42
+ ) {
43
+ store.optimisticHeader = update.attestedHeader;
44
+ }
45
+
46
+ // Update finalized header
47
+ if (
48
+ syncCommitteeTrueBits * 3 >= SYNC_COMMITTEE_SIZE * 2 &&
49
+ update.finalizedHeader.beacon.slot > store.finalizedHeader.beacon.slot
50
+ ) {
51
+ store.finalizedHeader = update.finalizedHeader;
52
+ if (store.finalizedHeader.beacon.slot > store.optimisticHeader.beacon.slot) {
53
+ store.optimisticHeader = store.finalizedHeader;
54
+ }
55
+ }
56
+
57
+ if (isSyncCommitteeUpdate(update)) {
58
+ // Update the best update in case we have to force-update to it if the timeout elapses
59
+ const bestValidUpdate = store.bestValidUpdates.get(updateSignaturePeriod);
60
+ const updateSummary = toLightClientUpdateSummary(update);
61
+ if (!bestValidUpdate || isBetterUpdate(updateSummary, bestValidUpdate.summary)) {
62
+ store.bestValidUpdates.set(updateSignaturePeriod, {update, summary: updateSummary});
63
+ pruneSetToMax(store.bestValidUpdates, MAX_SYNC_PERIODS_CACHE);
64
+ }
65
+
66
+ // Note: defer update next sync committee to a future getSyncCommitteeAtPeriod() call
67
+ }
68
+ }
69
+
70
+ export function getSyncCommitteeAtPeriod(
71
+ store: ILightClientStore,
72
+ period: SyncPeriod,
73
+ opts: ProcessUpdateOpts
74
+ ): SyncCommitteeFast {
75
+ const syncCommittee = store.syncCommittees.get(period);
76
+ if (syncCommittee) {
77
+ return syncCommittee;
78
+ }
79
+
80
+ const bestValidUpdate = store.bestValidUpdates.get(period - 1);
81
+ if (bestValidUpdate && (isSafeLightClientUpdate(bestValidUpdate.summary) || opts.allowForcedUpdates)) {
82
+ const {update} = bestValidUpdate;
83
+ const syncCommittee = deserializeSyncCommittee(update.nextSyncCommittee);
84
+ store.syncCommittees.set(period, syncCommittee);
85
+ pruneSetToMax(store.syncCommittees, MAX_SYNC_PERIODS_CACHE);
86
+ store.bestValidUpdates.delete(period - 1);
87
+
88
+ if (opts.updateHeadersOnForcedUpdate) {
89
+ // From https://github.com/ethereum/consensus-specs/blob/a57e15636013eeba3610ff3ade41781dba1bb0cd/specs/altair/light-client/sync-protocol.md?plain=1#L403
90
+ if (update.finalizedHeader.beacon.slot <= store.finalizedHeader.beacon.slot) {
91
+ update.finalizedHeader = update.attestedHeader;
92
+ }
93
+
94
+ // From https://github.com/ethereum/consensus-specs/blob/a57e15636013eeba3610ff3ade41781dba1bb0cd/specs/altair/light-client/sync-protocol.md?plain=1#L374
95
+ if (update.finalizedHeader.beacon.slot > store.finalizedHeader.beacon.slot) {
96
+ store.finalizedHeader = update.finalizedHeader;
97
+ }
98
+ if (store.finalizedHeader.beacon.slot > store.optimisticHeader.beacon.slot) {
99
+ store.optimisticHeader = store.finalizedHeader;
100
+ }
101
+ }
102
+
103
+ return syncCommittee;
104
+ }
105
+
106
+ const availableSyncCommittees = Array.from(store.syncCommittees.keys());
107
+ const availableBestValidUpdates = Array.from(store.bestValidUpdates.keys());
108
+ throw Error(
109
+ `No SyncCommittee for period ${period}` +
110
+ ` available syncCommittees ${JSON.stringify(availableSyncCommittees)}` +
111
+ ` available bestValidUpdates ${JSON.stringify(availableBestValidUpdates)}`
112
+ );
113
+ }
114
+
115
+ export function isSafeLightClientUpdate(update: LightClientUpdateSummary): boolean {
116
+ return (
117
+ update.activeParticipants * 3 >= SYNC_COMMITTEE_SIZE * 2 && update.isFinalityUpdate && update.isSyncCommitteeUpdate
118
+ );
119
+ }