@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.
- package/lib/events.d.ts +1 -5
- package/lib/events.d.ts.map +1 -0
- package/lib/events.js.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +10 -5
- package/lib/index.js.map +1 -1
- package/lib/spec/index.d.ts +1 -1
- package/lib/spec/index.d.ts.map +1 -0
- package/lib/spec/index.js +3 -0
- package/lib/spec/index.js.map +1 -1
- package/lib/spec/isBetterUpdate.d.ts.map +1 -0
- package/lib/spec/processLightClientUpdate.d.ts.map +1 -0
- package/lib/spec/store.d.ts.map +1 -0
- package/lib/spec/store.js +7 -3
- package/lib/spec/store.js.map +1 -1
- package/lib/spec/utils.d.ts.map +1 -0
- package/lib/spec/utils.js.map +1 -1
- package/lib/spec/validateLightClientBootstrap.d.ts.map +1 -0
- package/lib/spec/validateLightClientUpdate.d.ts.map +1 -0
- package/lib/transport/index.d.ts.map +1 -0
- package/lib/transport/interface.d.ts.map +1 -0
- package/lib/transport/rest.d.ts +1 -1
- package/lib/transport/rest.d.ts.map +1 -0
- package/lib/transport/rest.js +5 -4
- package/lib/transport/rest.js.map +1 -1
- package/lib/transport.d.ts.map +1 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/utils/api.d.ts.map +1 -0
- package/lib/utils/chunkify.d.ts.map +1 -0
- package/lib/utils/clock.d.ts.map +1 -0
- package/lib/utils/domain.d.ts.map +1 -0
- package/lib/utils/index.d.ts.map +1 -0
- package/lib/utils/logger.d.ts.map +1 -0
- package/lib/utils/map.d.ts.map +1 -0
- package/lib/utils/normalizeMerkleBranch.d.ts.map +1 -0
- package/lib/utils/update.d.ts.map +1 -0
- package/lib/utils/utils.d.ts.map +1 -0
- package/lib/utils/verifyMerkleBranch.d.ts.map +1 -0
- package/lib/utils.d.ts.map +1 -0
- package/lib/validation.d.ts.map +1 -0
- package/package.json +16 -18
- package/src/events.ts +17 -0
- package/src/index.ts +340 -0
- package/src/spec/index.ts +71 -0
- package/src/spec/isBetterUpdate.ts +94 -0
- package/src/spec/processLightClientUpdate.ts +119 -0
- package/src/spec/store.ts +105 -0
- package/src/spec/utils.ts +266 -0
- package/src/spec/validateLightClientBootstrap.ts +41 -0
- package/src/spec/validateLightClientUpdate.ts +154 -0
- package/src/transport/index.ts +2 -0
- package/src/transport/interface.ts +37 -0
- package/src/transport/rest.ts +89 -0
- package/src/transport.ts +2 -0
- package/src/types.ts +20 -0
- package/src/utils/api.ts +19 -0
- package/src/utils/chunkify.ts +26 -0
- package/src/utils/clock.ts +45 -0
- package/src/utils/domain.ts +44 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/logger.ts +29 -0
- package/src/utils/map.ts +20 -0
- package/src/utils/normalizeMerkleBranch.ts +15 -0
- package/src/utils/update.ts +30 -0
- package/src/utils/utils.ts +95 -0
- package/src/utils/verifyMerkleBranch.ts +29 -0
- package/src/utils.ts +2 -0
- 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
|
+
}
|