@lodestar/validator 1.51.0-dev.bf4ea87c7b → 1.51.0-dev.c6e566f915

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 (45) hide show
  1. package/lib/services/chainHeaderTracker.d.ts.map +1 -1
  2. package/lib/services/chainHeaderTracker.js +6 -1
  3. package/lib/services/chainHeaderTracker.js.map +1 -1
  4. package/lib/services/emitter.d.ts +10 -1
  5. package/lib/services/emitter.d.ts.map +1 -1
  6. package/lib/services/emitter.js +4 -0
  7. package/lib/services/emitter.js.map +1 -1
  8. package/lib/services/inclusionList.d.ts +25 -0
  9. package/lib/services/inclusionList.d.ts.map +1 -0
  10. package/lib/services/inclusionList.js +101 -0
  11. package/lib/services/inclusionList.js.map +1 -0
  12. package/lib/services/inclusionListDuties.d.ts +59 -0
  13. package/lib/services/inclusionListDuties.d.ts.map +1 -0
  14. package/lib/services/inclusionListDuties.js +252 -0
  15. package/lib/services/inclusionListDuties.js.map +1 -0
  16. package/lib/services/validatorStore.d.ts +7 -1
  17. package/lib/services/validatorStore.d.ts.map +1 -1
  18. package/lib/services/validatorStore.js +29 -3
  19. package/lib/services/validatorStore.js.map +1 -1
  20. package/lib/slashingProtection/attestation/index.d.ts +1 -0
  21. package/lib/slashingProtection/attestation/index.d.ts.map +1 -1
  22. package/lib/slashingProtection/attestation/index.js +77 -44
  23. package/lib/slashingProtection/attestation/index.js.map +1 -1
  24. package/lib/slashingProtection/index.d.ts +2 -0
  25. package/lib/slashingProtection/index.d.ts.map +1 -1
  26. package/lib/slashingProtection/index.js +48 -5
  27. package/lib/slashingProtection/index.js.map +1 -1
  28. package/lib/util/externalSignerClient.d.ts +6 -2
  29. package/lib/util/externalSignerClient.d.ts.map +1 -1
  30. package/lib/util/externalSignerClient.js +4 -0
  31. package/lib/util/externalSignerClient.js.map +1 -1
  32. package/lib/validator.d.ts +4 -1
  33. package/lib/validator.d.ts.map +1 -1
  34. package/lib/validator.js +7 -1
  35. package/lib/validator.js.map +1 -1
  36. package/package.json +12 -12
  37. package/src/services/chainHeaderTracker.ts +8 -1
  38. package/src/services/emitter.ts +10 -0
  39. package/src/services/inclusionList.ts +140 -0
  40. package/src/services/inclusionListDuties.ts +306 -0
  41. package/src/services/validatorStore.ts +43 -3
  42. package/src/slashingProtection/attestation/index.ts +81 -46
  43. package/src/slashingProtection/index.ts +50 -6
  44. package/src/util/externalSignerClient.ts +8 -1
  45. package/src/validator.ts +18 -0
@@ -0,0 +1,306 @@
1
+ import {ApiClient, routes} from "@lodestar/api";
2
+ import {ChainForkConfig} from "@lodestar/config";
3
+ import {SLOTS_PER_EPOCH} from "@lodestar/params";
4
+ import {IClock, computeEpochAtSlot, isStartSlotOfEpoch} from "@lodestar/state-transition";
5
+ import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types";
6
+ import {sleep, toPubkeyHex} from "@lodestar/utils";
7
+ import {PubkeyHex} from "../types.js";
8
+ import {LoggerVc} from "../util/index.js";
9
+ import {ChainHeaderTracker, HeadEventData} from "./chainHeaderTracker.js";
10
+ import {SyncingStatusTracker} from "./syncingStatusTracker.js";
11
+ import {ValidatorStore} from "./validatorStore.js";
12
+
13
+ /** Only retain `HISTORICAL_DUTIES_EPOCHS` duties prior to the current epoch. */
14
+ const HISTORICAL_DUTIES_EPOCHS = 2;
15
+
16
+ type InclusionListDuty = routes.validator.InclusionListDuty;
17
+ // To assist with readability
18
+ type InclusionListDutiesAtEpoch = {dependentRoot: RootHex; dutiesByIndex: Map<ValidatorIndex, InclusionListDuty>};
19
+
20
+ /**
21
+ * Similar to AttestationDutiesService but
22
+ * - No generating and caching selection proof
23
+ * - No handling and maintaining subnet subscription
24
+ */
25
+ export class InclusionListDutiesService {
26
+ /** Maps a validator index to its duty for each epoch */
27
+ private readonly dutiesByIndexByEpoch = new Map<Epoch, InclusionListDutiesAtEpoch>();
28
+ /**
29
+ * We may receive new dependentRoot of an epoch but it's not the last slot of epoch
30
+ * so we have to wait for getting close to the next epoch to redownload new inclusionListDuties.
31
+ */
32
+ private readonly pendingDependentRootByEpoch = new Map<Epoch, RootHex>();
33
+
34
+ constructor(
35
+ private readonly config: ChainForkConfig,
36
+ private readonly logger: LoggerVc,
37
+ private readonly api: ApiClient,
38
+ private clock: IClock,
39
+ private readonly validatorStore: ValidatorStore,
40
+ chainHeadTracker: ChainHeaderTracker,
41
+ syncingStatusTracker: SyncingStatusTracker
42
+ ) {
43
+ // Running this task every epoch is safe since a re-org of two epochs is very unlikely
44
+ clock.runEveryEpoch(this.runDutiesTasks);
45
+ clock.runEverySlot(this.prepareForNextEpoch);
46
+ chainHeadTracker.runOnNewHead(this.onNewHead);
47
+ syncingStatusTracker.runOnResynced(async (slot) => {
48
+ // Skip on first slot of epoch since tasks are already scheduled
49
+ if (!isStartSlotOfEpoch(slot)) {
50
+ return this.runDutiesTasks(computeEpochAtSlot(slot));
51
+ }
52
+ });
53
+ }
54
+
55
+ removeDutiesForKey(pubkey: PubkeyHex): void {
56
+ for (const [epoch, dutiesAtEpoch] of this.dutiesByIndexByEpoch) {
57
+ for (const [vIndex, duty] of dutiesAtEpoch.dutiesByIndex) {
58
+ if (toPubkeyHex(duty.pubkey) === pubkey) {
59
+ dutiesAtEpoch.dutiesByIndex.delete(vIndex);
60
+ if (dutiesAtEpoch.dutiesByIndex.size === 0) {
61
+ this.dutiesByIndexByEpoch.delete(epoch);
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ /** Returns all `ValidatorDuty` for the given `slot` */
69
+ getDutiesAtSlot(slot: Slot): InclusionListDuty[] {
70
+ const epoch = computeEpochAtSlot(slot);
71
+ const duties: InclusionListDuty[] = [];
72
+ const epochDuties = this.dutiesByIndexByEpoch.get(epoch);
73
+ if (epochDuties === undefined) {
74
+ return duties;
75
+ }
76
+
77
+ for (const validatorDuty of epochDuties.dutiesByIndex.values()) {
78
+ if (validatorDuty.slot === slot) {
79
+ duties.push(validatorDuty);
80
+ }
81
+ }
82
+
83
+ return duties;
84
+ }
85
+
86
+ /**
87
+ * If a reorg dependent root comes at a slot other than last slot of epoch
88
+ * just update this.pendingDependentRootByEpoch() and process here
89
+ */
90
+ private prepareForNextEpoch = async (slot: Slot, signal: AbortSignal): Promise<void> => {
91
+ // only interested in last slot of epoch
92
+ if ((slot + 1) % SLOTS_PER_EPOCH !== 0) {
93
+ return;
94
+ }
95
+
96
+ // during the 1 / 3 of epoch, last block of epoch may come
97
+ await sleep(this.clock.msToSlot(slot + 1 / 3), signal);
98
+
99
+ const nextEpoch = computeEpochAtSlot(slot) + 1;
100
+ const dependentRoot = this.dutiesByIndexByEpoch.get(nextEpoch)?.dependentRoot;
101
+ const pendingDependentRoot = this.pendingDependentRootByEpoch.get(nextEpoch);
102
+ if (dependentRoot && pendingDependentRoot && dependentRoot !== pendingDependentRoot) {
103
+ // this happens when pendingDependentRoot is not the last block of an epoch
104
+ this.logger.info("Redownload inclusion list duties when it's close to epoch boundary", {nextEpoch, slot});
105
+ await this.handleInclusionListDutiesReorg(nextEpoch, slot, dependentRoot, pendingDependentRoot);
106
+ }
107
+ };
108
+
109
+ private runDutiesTasks = async (epoch: Epoch): Promise<void> => {
110
+ // pollInclusionListCommittee also polls epoch + 1, so start one epoch before the fork
111
+ if (epoch + 1 < this.config.HEZE_FORK_EPOCH) {
112
+ return;
113
+ }
114
+
115
+ await Promise.all([
116
+ // Run pollInclusionListCommittee immediately for all known local indices
117
+ this.pollInclusionListCommittee(epoch, this.validatorStore.getAllLocalIndices()).catch((e: Error) => {
118
+ this.logger.error("Error on poll inclusion list committee", {epoch}, e);
119
+ }),
120
+
121
+ // At the same time fetch any remaining unknown validator indices, then poll duties for those newIndices only
122
+ this.validatorStore
123
+ .pollValidatorIndices()
124
+ .then((newIndices) => this.pollInclusionListCommittee(epoch, newIndices))
125
+ .catch((e: Error) => {
126
+ this.logger.error("Error on poll indices and inclusion list committee", {epoch}, e);
127
+ }),
128
+ ]);
129
+
130
+ // After both, prune
131
+ this.pruneOldDuties(epoch);
132
+ };
133
+
134
+ private async pollInclusionListCommittee(currentEpoch: Epoch, indexArr: ValidatorIndex[]): Promise<void> {
135
+ const nextEpoch = currentEpoch + 1;
136
+
137
+ // No need to bother the BN if we don't have any validators.
138
+ if (indexArr.length === 0) {
139
+ return;
140
+ }
141
+
142
+ for (const epoch of [currentEpoch, nextEpoch]) {
143
+ // Download the duties and update the duties for the current and next epoch.
144
+ await this.pollInclusionListCommitteeForEpoch(epoch, indexArr).catch((e: Error) => {
145
+ this.logger.error("Failed to download inclusion list duties", {epoch}, e);
146
+ });
147
+ }
148
+ }
149
+
150
+ /**
151
+ * For the given `indexArr`, download the duties for the given `epoch` and store them in duties.
152
+ */
153
+ private async pollInclusionListCommitteeForEpoch(epoch: Epoch, indexArr: ValidatorIndex[]): Promise<void> {
154
+ // Don't fetch duties for epochs before genesis. However, should fetch epoch 0 duties at epoch -1
155
+ if (epoch < 0) {
156
+ return;
157
+ }
158
+
159
+ // Inclusion list committees only exist post-heze; the beacon node rejects earlier epochs
160
+ if (epoch < this.config.HEZE_FORK_EPOCH) {
161
+ return;
162
+ }
163
+
164
+ const res = await this.api.validator.getInclusionListCommitteeDuties({epoch, indices: indexArr});
165
+ const inclusionListDuties = res.value();
166
+ const {dependentRoot} = res.meta();
167
+ const relevantDuties = inclusionListDuties.filter((duty) => {
168
+ const pubkeyHex = toPubkeyHex(duty.pubkey);
169
+ return this.validatorStore.hasVotingPubkey(pubkeyHex) && this.validatorStore.isDoppelgangerSafe(pubkeyHex);
170
+ });
171
+
172
+ this.logger.debug("Downloaded inclusion list duties", {epoch, dependentRoot, count: relevantDuties.length});
173
+
174
+ const dutiesAtEpoch = this.dutiesByIndexByEpoch.get(epoch);
175
+ const priorDependentRoot = dutiesAtEpoch?.dependentRoot;
176
+ const dependentRootChanged = priorDependentRoot !== undefined && priorDependentRoot !== dependentRoot;
177
+
178
+ if (!priorDependentRoot || dependentRootChanged) {
179
+ const dutiesByIndex = new Map<ValidatorIndex, InclusionListDuty>();
180
+ for (const duty of relevantDuties) {
181
+ dutiesByIndex.set(duty.validatorIndex, duty);
182
+ }
183
+ this.dutiesByIndexByEpoch.set(epoch, {dependentRoot, dutiesByIndex});
184
+
185
+ if (priorDependentRoot && dependentRootChanged) {
186
+ this.logger.warn("Inclusion list duties re-org. This may happen from time to time", {
187
+ priorDependentRoot: priorDependentRoot,
188
+ dependentRoot: dependentRoot,
189
+ epoch,
190
+ });
191
+ }
192
+ } else {
193
+ const existingDuties = dutiesAtEpoch.dutiesByIndex;
194
+ const existingDutiesCount = existingDuties.size;
195
+ for (const duty of relevantDuties) {
196
+ if (!existingDuties.has(duty.validatorIndex)) {
197
+ existingDuties.set(duty.validatorIndex, duty);
198
+ }
199
+ }
200
+
201
+ if (existingDuties.size > existingDutiesCount) {
202
+ this.logger.debug("Discovered new inclusion list duties", {
203
+ epoch,
204
+ dependentRoot,
205
+ count: existingDuties.size - existingDutiesCount,
206
+ });
207
+ }
208
+ }
209
+ }
210
+
211
+ private async handleInclusionListDutiesReorg(
212
+ dutyEpoch: Epoch,
213
+ slot: Slot,
214
+ oldDependentRoot: RootHex,
215
+ newDependentRoot: RootHex
216
+ ): Promise<void> {
217
+ const logContext = {dutyEpoch, slot, oldDependentRoot, newDependentRoot};
218
+ this.logger.debug("Redownload inclusion list duties", logContext);
219
+
220
+ await this.pollInclusionListCommitteeForEpoch(dutyEpoch, this.validatorStore.getAllLocalIndices())
221
+ .then(() => {
222
+ this.pendingDependentRootByEpoch.delete(dutyEpoch);
223
+ })
224
+ .catch((e: Error) => {
225
+ this.logger.error("Failed to redownload inclusion list duties when reorg happens", logContext, e);
226
+ });
227
+ }
228
+
229
+ /**
230
+ * inclusion list duties may be reorged due to 2 scenarios:
231
+ * 1. node is syncing (for nextEpoch duties)
232
+ * 2. node is reorged
233
+ * previousDutyDependentRoot = get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch - 1) - 1)
234
+ * => dependent root of current epoch
235
+ * currentDutyDependentRoot = get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch) - 1)
236
+ * => dependent root of next epoch
237
+ */
238
+ private onNewHead = async ({
239
+ slot,
240
+ head,
241
+ previousDutyDependentRoot,
242
+ currentDutyDependentRoot,
243
+ }: HeadEventData): Promise<void> => {
244
+ const currentEpoch = computeEpochAtSlot(slot);
245
+ const nextEpoch = currentEpoch + 1;
246
+ const nextTwoEpoch = currentEpoch + 2;
247
+ const nextTwoEpochDependentRoot = this.dutiesByIndexByEpoch.get(currentEpoch + 2)?.dependentRoot;
248
+
249
+ // this may happen ONLY when node is syncing
250
+ // it's safe to get inclusion list duties at epoch n + 1 thanks to nextEpochShuffling cache
251
+ // but it's an issue to request inclusion list duties for epoch n + 2 as dependent root keeps changing while node is syncing
252
+ // see https://github.com/ChainSafe/lodestar/issues/3211
253
+ if (nextTwoEpochDependentRoot && head !== nextTwoEpochDependentRoot) {
254
+ // last slot of epoch, we're sure it's the correct dependent root
255
+ if ((slot + 1) % SLOTS_PER_EPOCH === 0) {
256
+ this.logger.info("Next 2 epoch inclusion list duties reorg", {slot, dutyEpoch: nextTwoEpoch, head});
257
+ await this.handleInclusionListDutiesReorg(nextTwoEpoch, slot, nextTwoEpochDependentRoot, head);
258
+ } else {
259
+ this.logger.debug("Potential next 2 epoch inclusion list duties reorg", {slot, dutyEpoch: nextTwoEpoch, head});
260
+ // node may send adjacent onHead events while it's syncing
261
+ // wait for getting close to next epoch to make sure the dependRoot
262
+ this.pendingDependentRootByEpoch.set(nextTwoEpoch, head);
263
+ }
264
+ }
265
+
266
+ // dependent root for next epoch changed
267
+ const nextEpochDependentRoot = this.dutiesByIndexByEpoch.get(nextEpoch)?.dependentRoot;
268
+ if (nextEpochDependentRoot && currentDutyDependentRoot !== nextEpochDependentRoot) {
269
+ this.logger.warn("Potential next epoch inclusion list duties reorg", {
270
+ slot,
271
+ dutyEpoch: nextEpoch,
272
+ priorDependentRoot: nextEpochDependentRoot,
273
+ newDependentRoot: currentDutyDependentRoot,
274
+ });
275
+ await this.handleInclusionListDutiesReorg(nextEpoch, slot, nextEpochDependentRoot, currentDutyDependentRoot);
276
+ }
277
+
278
+ // dependent root for current epoch changed
279
+ const currentEpochDependentRoot = this.dutiesByIndexByEpoch.get(currentEpoch)?.dependentRoot;
280
+ if (currentEpochDependentRoot && currentEpochDependentRoot !== previousDutyDependentRoot) {
281
+ this.logger.warn("Potential current epoch inclusion list duties reorg", {
282
+ slot,
283
+ dutyEpoch: currentEpoch,
284
+ priorDependentRoot: currentEpochDependentRoot,
285
+ newDependentRoot: previousDutyDependentRoot,
286
+ });
287
+ await this.handleInclusionListDutiesReorg(
288
+ currentEpoch,
289
+ slot,
290
+ currentEpochDependentRoot,
291
+ previousDutyDependentRoot
292
+ );
293
+ }
294
+ };
295
+
296
+ /** Run once per epoch to prune duties map */
297
+ private pruneOldDuties(currentEpoch: Epoch): void {
298
+ for (const byEpochMap of [this.dutiesByIndexByEpoch, this.pendingDependentRootByEpoch]) {
299
+ for (const epoch of byEpochMap.keys()) {
300
+ if (epoch + HISTORICAL_DUTIES_EPOCHS < currentEpoch) {
301
+ byEpochMap.delete(epoch);
302
+ }
303
+ }
304
+ }
305
+ }
306
+ }
@@ -11,6 +11,7 @@ import {
11
11
  DOMAIN_BEACON_PROPOSER,
12
12
  DOMAIN_BUILDER_REQUEST_AUTH,
13
13
  DOMAIN_CONTRIBUTION_AND_PROOF,
14
+ DOMAIN_INCLUSION_LIST_COMMITTEE,
14
15
  DOMAIN_PROPOSER_PREFERENCES,
15
16
  DOMAIN_PTC_ATTESTER,
16
17
  DOMAIN_RANDAO,
@@ -46,6 +47,7 @@ import {
46
47
  altair,
47
48
  bellatrix,
48
49
  gloas,
50
+ heze,
49
51
  phase0,
50
52
  ssz,
51
53
  } from "@lodestar/types";
@@ -633,7 +635,8 @@ export class ValidatorStore {
633
635
  removeSigner(pubkeyHex: PubkeyHex): boolean {
634
636
  this.doppelgangerService?.unregisterValidator(pubkeyHex);
635
637
 
636
- return this.indicesService.removeForKey(pubkeyHex) || this.validators.delete(pubkeyHex);
638
+ this.indicesService.removeForKey(pubkeyHex);
639
+ return this.validators.delete(pubkeyHex);
637
640
  }
638
641
 
639
642
  /** Return true if there is at least 1 pubkey registered */
@@ -809,6 +812,8 @@ export class ValidatorStore {
809
812
  ): Promise<SignedAggregateAndProof> {
810
813
  this.validateAttestationDuty(duty, aggregate.data);
811
814
 
815
+ this.assertDoppelgangerSafe(duty.pubkey);
816
+
812
817
  const aggregateAndProof: AggregateAndProof = {
813
818
  aggregate,
814
819
  aggregatorIndex: duty.validatorIndex,
@@ -929,8 +934,6 @@ export class ValidatorStore {
929
934
  throw Error(`Not signing proposer preferences for past slot ${duty.slot} (current ${currentSlot})`);
930
935
  }
931
936
 
932
- this.assertDoppelgangerSafe(duty.pubkey);
933
-
934
937
  const message: gloas.ProposerPreferences = {
935
938
  dependentRoot,
936
939
  proposalSlot: duty.slot,
@@ -954,6 +957,38 @@ export class ValidatorStore {
954
957
  };
955
958
  }
956
959
 
960
+ async signInclusionList(
961
+ duty: routes.validator.InclusionListDuty,
962
+ inclusionList: heze.InclusionList
963
+ ): Promise<heze.SignedInclusionList> {
964
+ if (duty.slot !== inclusionList.slot) {
965
+ throw Error(
966
+ `Inconsistent duties during signing: duty.slot ${duty.slot} != inclusionList.slot ${inclusionList.slot}`
967
+ );
968
+ }
969
+ if (duty.validatorIndex !== inclusionList.validatorIndex) {
970
+ throw Error(
971
+ `Inconsistent duties during signing: duty.validatorIndex ${duty.validatorIndex} != inclusionList.validatorIndex ${inclusionList.validatorIndex}`
972
+ );
973
+ }
974
+
975
+ this.assertDoppelgangerSafe(duty.pubkey);
976
+
977
+ const signingSlot = inclusionList.slot;
978
+ const domain = this.config.getDomain(signingSlot, DOMAIN_INCLUSION_LIST_COMMITTEE);
979
+ const signingRoot = computeSigningRoot(ssz.heze.InclusionList, inclusionList, domain);
980
+
981
+ const signableMessage: SignableMessage = {
982
+ type: SignableMessageType.INCLUSION_LIST,
983
+ data: inclusionList,
984
+ };
985
+
986
+ return {
987
+ message: inclusionList,
988
+ signature: await this.getSignature(duty.pubkey, signingRoot, signingSlot, signableMessage),
989
+ };
990
+ }
991
+
957
992
  async signAttestationSelectionProof(pubkey: BLSPubkeyMaybeHex, slot: Slot): Promise<BLSSignature> {
958
993
  const signingSlot = slot;
959
994
  const domain = this.config.getDomain(slot, DOMAIN_SELECTION_PROOF);
@@ -1214,6 +1249,11 @@ export class ValidatorStore {
1214
1249
  }
1215
1250
  }
1216
1251
 
1252
+ /**
1253
+ * Required before signing a message that is slashable or that the beacon node counts as validator
1254
+ * liveness (see `validatorSeenAtEpoch`), otherwise doppelganger detection would flag our own messages.
1255
+ * Skip it for anything else.
1256
+ */
1217
1257
  private assertDoppelgangerSafe(pubKey: PubkeyHex | BLSPubkey): void {
1218
1258
  const pubkeyHex = typeof pubKey === "string" ? pubKey : toPubkeyHex(pubKey);
1219
1259
  if (!this.isDoppelgangerSafe(pubkeyHex)) {
@@ -1,4 +1,5 @@
1
1
  import {BLSPubkey, Epoch} from "@lodestar/types";
2
+ import {defer, toPubkeyHex} from "@lodestar/utils";
2
3
  import {MinMaxSurround, SurroundAttestationError, SurroundAttestationErrorCode} from "../minMaxSurround/index.js";
3
4
  import {SlashingProtectionAttestation} from "../types.js";
4
5
  import {ZERO_ROOT, isEqualNonZeroRoot, isEqualRoot, minEpoch} from "../utils.js";
@@ -21,6 +22,7 @@ export class SlashingProtectionAttestationService {
21
22
  private attestationByTarget: AttestationByTargetRepository;
22
23
  private attestationLowerBound: AttestationLowerBoundRepository;
23
24
  private minMaxSurround: MinMaxSurround;
25
+ private readonly pendingAttestations = new Map<string, Promise<void>>();
24
26
 
25
27
  constructor(
26
28
  signedAttestationDb: AttestationByTargetRepository,
@@ -37,10 +39,25 @@ export class SlashingProtectionAttestationService {
37
39
  * This is the safe, externally-callable interface for checking attestations
38
40
  */
39
41
  async checkAndInsertAttestation(pubKey: BLSPubkey, attestation: SlashingProtectionAttestation): Promise<void> {
40
- const safeStatus = await this.checkAttestation(pubKey, attestation);
42
+ const pubkeyHex = toPubkeyHex(pubKey);
43
+ const previous = this.pendingAttestations.get(pubkeyHex);
44
+ const {promise, resolve} = defer<void>();
45
+ this.pendingAttestations.set(pubkeyHex, promise);
41
46
 
42
- if (safeStatus !== SafeStatus.SAME_DATA) {
43
- await this.insertAttestation(pubKey, attestation);
47
+ try {
48
+ if (previous !== undefined) {
49
+ await previous;
50
+ }
51
+ const safeStatus = await this.checkAttestation(pubKey, attestation);
52
+
53
+ if (safeStatus !== SafeStatus.SAME_DATA) {
54
+ await this.insertAttestation(pubKey, attestation);
55
+ }
56
+ } finally {
57
+ resolve();
58
+ if (this.pendingAttestations.get(pubkeyHex) === promise) {
59
+ this.pendingAttestations.delete(pubkeyHex);
60
+ }
44
61
  }
45
62
 
46
63
  // TODO: Implement safe clean-up of stored attestations
@@ -164,55 +181,73 @@ export class SlashingProtectionAttestationService {
164
181
  * Interchange import / export functionality
165
182
  */
166
183
  async importAttestations(pubkey: BLSPubkey, attestations: SlashingProtectionAttestation[]): Promise<void> {
167
- // Min-max surround misses a surround vote beyond its lookback, require the highest target attestation to
168
- // have the highest source epoch as `checkAttestation` relies on it
169
- let latestAtt = await this.attestationByTarget.getLatest(pubkey);
170
- let maxSourceAtt = latestAtt;
171
- for (const attestation of attestations) {
172
- if (latestAtt === null || attestation.targetEpoch >= latestAtt.targetEpoch) {
173
- latestAtt = attestation;
184
+ const pubkeyHex = toPubkeyHex(pubkey);
185
+ const previous = this.pendingAttestations.get(pubkeyHex);
186
+ const {promise, resolve} = defer<void>();
187
+ this.pendingAttestations.set(pubkeyHex, promise);
188
+
189
+ try {
190
+ if (previous !== undefined) {
191
+ await previous;
174
192
  }
175
- if (maxSourceAtt === null || attestation.sourceEpoch > maxSourceAtt.sourceEpoch) {
176
- maxSourceAtt = attestation;
193
+ // Min-max surround misses a surround vote beyond its lookback, require the highest target attestation to
194
+ // have the highest source epoch as `checkAttestation` relies on it
195
+ let latestAtt = await this.attestationByTarget.getLatest(pubkey);
196
+ let maxSourceAtt = latestAtt;
197
+ for (const attestation of attestations) {
198
+ if (latestAtt === null || attestation.targetEpoch >= latestAtt.targetEpoch) {
199
+ latestAtt = attestation;
200
+ }
201
+ if (maxSourceAtt === null || attestation.sourceEpoch > maxSourceAtt.sourceEpoch) {
202
+ maxSourceAtt = attestation;
203
+ }
177
204
  }
178
- }
179
- if (latestAtt && maxSourceAtt && latestAtt.sourceEpoch < maxSourceAtt.sourceEpoch) {
180
- throw new InvalidAttestationError({
181
- code: InvalidAttestationErrorCode.NEW_SURROUNDS_PREV,
182
- attestation: latestAtt,
183
- prev: maxSourceAtt,
184
- });
185
- }
186
-
187
- // Never replace a recorded attestation with a different source or signing root, a zero root refuses any attestation
188
- // with that target. The highest source epoch is kept as the latest attestation must have it, see above.
189
- const attestationsByTarget = new Map<Epoch, SlashingProtectionAttestation>();
190
- for (const attestation of attestations) {
191
- const {sourceEpoch, targetEpoch, signingRoot} = attestation;
192
- const prevAtt =
193
- attestationsByTarget.get(targetEpoch) ?? (await this.attestationByTarget.get(pubkey, targetEpoch));
194
- if (prevAtt === null || (prevAtt.sourceEpoch === sourceEpoch && isEqualRoot(prevAtt.signingRoot, signingRoot))) {
195
- attestationsByTarget.set(targetEpoch, attestation);
196
- } else {
197
- attestationsByTarget.set(targetEpoch, {
198
- sourceEpoch: Math.max(prevAtt.sourceEpoch, sourceEpoch),
199
- targetEpoch,
200
- signingRoot: ZERO_ROOT,
205
+ if (latestAtt && maxSourceAtt && latestAtt.sourceEpoch < maxSourceAtt.sourceEpoch) {
206
+ throw new InvalidAttestationError({
207
+ code: InvalidAttestationErrorCode.NEW_SURROUNDS_PREV,
208
+ attestation: latestAtt,
209
+ prev: maxSourceAtt,
201
210
  });
202
211
  }
203
- }
204
- await this.attestationByTarget.set(pubkey, Array.from(attestationsByTarget.values()));
205
212
 
206
- // Pre-compute spans for all attestations
207
- for (const attestation of attestations) {
208
- await this.minMaxSurround.insertAttestation(pubkey, attestation);
209
- }
213
+ // Never replace a recorded attestation with a different source or signing root, a zero root refuses any attestation
214
+ // with that target. The highest source epoch is kept as the latest attestation must have it, see above.
215
+ const attestationsByTarget = new Map<Epoch, SlashingProtectionAttestation>();
216
+ for (const attestation of attestations) {
217
+ const {sourceEpoch, targetEpoch, signingRoot} = attestation;
218
+ const prevAtt =
219
+ attestationsByTarget.get(targetEpoch) ?? (await this.attestationByTarget.get(pubkey, targetEpoch));
220
+ if (
221
+ prevAtt === null ||
222
+ (prevAtt.sourceEpoch === sourceEpoch && isEqualRoot(prevAtt.signingRoot, signingRoot))
223
+ ) {
224
+ attestationsByTarget.set(targetEpoch, attestation);
225
+ } else {
226
+ attestationsByTarget.set(targetEpoch, {
227
+ sourceEpoch: Math.max(prevAtt.sourceEpoch, sourceEpoch),
228
+ targetEpoch,
229
+ signingRoot: ZERO_ROOT,
230
+ });
231
+ }
232
+ }
233
+ await this.attestationByTarget.set(pubkey, Array.from(attestationsByTarget.values()));
234
+
235
+ // Pre-compute spans for all attestations
236
+ for (const attestation of attestations) {
237
+ await this.minMaxSurround.insertAttestation(pubkey, attestation);
238
+ }
210
239
 
211
- // Pre-compute and store lower-bound
212
- const minSourceEpoch = minEpoch(attestations.map((attestation) => attestation.sourceEpoch));
213
- const minTargetEpoch = minEpoch(attestations.map((attestation) => attestation.targetEpoch));
214
- if (minSourceEpoch != null && minTargetEpoch != null) {
215
- await this.attestationLowerBound.set(pubkey, {minSourceEpoch, minTargetEpoch});
240
+ // Pre-compute and store lower-bound
241
+ const minSourceEpoch = minEpoch(attestations.map((attestation) => attestation.sourceEpoch));
242
+ const minTargetEpoch = minEpoch(attestations.map((attestation) => attestation.targetEpoch));
243
+ if (minSourceEpoch != null && minTargetEpoch != null) {
244
+ await this.attestationLowerBound.set(pubkey, {minSourceEpoch, minTargetEpoch});
245
+ }
246
+ } finally {
247
+ resolve();
248
+ if (this.pendingAttestations.get(pubkeyHex) === promise) {
249
+ this.pendingAttestations.delete(pubkeyHex);
250
+ }
216
251
  }
217
252
  }
218
253
 
@@ -1,8 +1,8 @@
1
1
  import {GENESIS_EPOCH} from "@lodestar/params";
2
2
  import {BLSPubkey, Epoch, Root} from "@lodestar/types";
3
- import {Logger, toPubkeyHex} from "@lodestar/utils";
3
+ import {Logger, defer, toPubkeyHex} from "@lodestar/utils";
4
4
  import {uniqueVectorArr} from "../slashingProtection/utils.js";
5
- import {LodestarValidatorDatabaseController} from "../types.js";
5
+ import {LodestarValidatorDatabaseController, PubkeyHex} from "../types.js";
6
6
  import {
7
7
  AttestationByTargetRepository,
8
8
  AttestationLowerBoundRepository,
@@ -34,6 +34,8 @@ export type {ISlashingProtection, InterchangeFormatVersion, SlashingProtectionBl
34
34
  export class SlashingProtection implements ISlashingProtection {
35
35
  private blockService: SlashingProtectionBlockService;
36
36
  private attestationService: SlashingProtectionAttestationService;
37
+ private readonly importing = new Map<PubkeyHex, Promise<void>>();
38
+ private lastImport: Promise<void> | null = null;
37
39
 
38
40
  constructor(protected db: LodestarValidatorDatabaseController) {
39
41
  const blockBySlotRepository = new BlockBySlotRepository(db);
@@ -51,10 +53,26 @@ export class SlashingProtection implements ISlashingProtection {
51
53
  }
52
54
 
53
55
  async checkAndInsertBlockProposal(pubKey: BLSPubkey, block: SlashingProtectionBlock): Promise<void> {
56
+ if (this.importing.size !== 0) {
57
+ const pubkeyHex = toPubkeyHex(pubKey);
58
+ let importing = this.importing.get(pubkeyHex);
59
+ while (importing !== undefined) {
60
+ await importing;
61
+ importing = this.importing.get(pubkeyHex);
62
+ }
63
+ }
54
64
  await this.blockService.checkAndInsertBlockProposal(pubKey, block);
55
65
  }
56
66
 
57
67
  async checkAndInsertAttestation(pubKey: BLSPubkey, attestation: SlashingProtectionAttestation): Promise<void> {
68
+ if (this.importing.size !== 0) {
69
+ const pubkeyHex = toPubkeyHex(pubKey);
70
+ let importing = this.importing.get(pubkeyHex);
71
+ while (importing !== undefined) {
72
+ await importing;
73
+ importing = this.importing.get(pubkeyHex);
74
+ }
75
+ }
58
76
  await this.attestationService.checkAndInsertAttestation(pubKey, attestation);
59
77
  }
60
78
 
@@ -84,10 +102,36 @@ export class SlashingProtection implements ISlashingProtection {
84
102
  }
85
103
  }
86
104
  }
87
- for (const validator of data) {
88
- logger?.info("Importing slashing protection", {pubkey: toPubkeyHex(validator.pubkey)});
89
- await this.blockService.importBlocks(validator.pubkey, validator.signedBlocks);
90
- await this.attestationService.importAttestations(validator.pubkey, validator.signedAttestations);
105
+
106
+ // Checks for keys in the file wait for the whole import, a key that is already signing must not sign against
107
+ // a history the import is still extending. Imports run one after another so a later one cannot release a key early.
108
+ const pubkeyHexes = data.map((validator) => toPubkeyHex(validator.pubkey));
109
+ const previous = this.lastImport;
110
+ const {promise, resolve} = defer<void>();
111
+ this.lastImport = promise;
112
+ for (const pubkeyHex of pubkeyHexes) {
113
+ this.importing.set(pubkeyHex, promise);
114
+ }
115
+
116
+ try {
117
+ if (previous !== null) {
118
+ await previous;
119
+ }
120
+ for (const validator of data) {
121
+ logger?.info("Importing slashing protection", {pubkey: toPubkeyHex(validator.pubkey)});
122
+ await this.blockService.importBlocks(validator.pubkey, validator.signedBlocks);
123
+ await this.attestationService.importAttestations(validator.pubkey, validator.signedAttestations);
124
+ }
125
+ } finally {
126
+ resolve();
127
+ if (this.lastImport === promise) {
128
+ this.lastImport = null;
129
+ }
130
+ for (const pubkeyHex of pubkeyHexes) {
131
+ if (this.importing.get(pubkeyHex) === promise) {
132
+ this.importing.delete(pubkeyHex);
133
+ }
134
+ }
91
135
  }
92
136
  }
93
137