@lodestar/validator 1.43.0-dev.bc569affb9 → 1.43.0-dev.c98da75ec7

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 (59) hide show
  1. package/lib/metrics.d.ts +10 -0
  2. package/lib/metrics.d.ts.map +1 -1
  3. package/lib/metrics.js +37 -0
  4. package/lib/metrics.js.map +1 -1
  5. package/lib/services/block.d.ts +2 -1
  6. package/lib/services/block.d.ts.map +1 -1
  7. package/lib/services/block.js +4 -3
  8. package/lib/services/block.js.map +1 -1
  9. package/lib/services/blockDuties.d.ts +20 -4
  10. package/lib/services/blockDuties.d.ts.map +1 -1
  11. package/lib/services/blockDuties.js +18 -3
  12. package/lib/services/blockDuties.js.map +1 -1
  13. package/lib/services/chainHeaderTracker.d.ts +8 -2
  14. package/lib/services/chainHeaderTracker.d.ts.map +1 -1
  15. package/lib/services/chainHeaderTracker.js +23 -8
  16. package/lib/services/chainHeaderTracker.js.map +1 -1
  17. package/lib/services/emitter.d.ts +14 -1
  18. package/lib/services/emitter.d.ts.map +1 -1
  19. package/lib/services/emitter.js +22 -0
  20. package/lib/services/emitter.js.map +1 -1
  21. package/lib/services/proposerPreferences.d.ts +25 -0
  22. package/lib/services/proposerPreferences.d.ts.map +1 -0
  23. package/lib/services/proposerPreferences.js +101 -0
  24. package/lib/services/proposerPreferences.js.map +1 -0
  25. package/lib/services/ptc.d.ts +28 -0
  26. package/lib/services/ptc.d.ts.map +1 -0
  27. package/lib/services/ptc.js +89 -0
  28. package/lib/services/ptc.js.map +1 -0
  29. package/lib/services/ptcDuties.d.ts +31 -0
  30. package/lib/services/ptcDuties.d.ts.map +1 -0
  31. package/lib/services/ptcDuties.js +201 -0
  32. package/lib/services/ptcDuties.js.map +1 -0
  33. package/lib/services/validatorStore.d.ts +3 -0
  34. package/lib/services/validatorStore.d.ts.map +1 -1
  35. package/lib/services/validatorStore.js +54 -1
  36. package/lib/services/validatorStore.js.map +1 -1
  37. package/lib/util/externalSignerClient.d.ts +9 -1
  38. package/lib/util/externalSignerClient.d.ts.map +1 -1
  39. package/lib/util/externalSignerClient.js +8 -0
  40. package/lib/util/externalSignerClient.js.map +1 -1
  41. package/lib/util/params.js +3 -0
  42. package/lib/util/params.js.map +1 -1
  43. package/lib/validator.d.ts +4 -1
  44. package/lib/validator.d.ts.map +1 -1
  45. package/lib/validator.js +13 -3
  46. package/lib/validator.js.map +1 -1
  47. package/package.json +12 -12
  48. package/src/metrics.ts +46 -0
  49. package/src/services/block.ts +3 -9
  50. package/src/services/blockDuties.ts +21 -6
  51. package/src/services/chainHeaderTracker.ts +31 -7
  52. package/src/services/emitter.ts +31 -0
  53. package/src/services/proposerPreferences.ts +124 -0
  54. package/src/services/ptc.ts +131 -0
  55. package/src/services/ptcDuties.ts +246 -0
  56. package/src/services/validatorStore.ts +79 -0
  57. package/src/util/externalSignerClient.ts +13 -1
  58. package/src/util/params.ts +3 -0
  59. package/src/validator.ts +39 -5
@@ -20,12 +20,12 @@ const HISTORICAL_DUTIES_EPOCHS = 2;
20
20
  const GENESIS_EPOCH = 0;
21
21
  export const GENESIS_SLOT = 0;
22
22
 
23
- type BlockDutyAtEpoch = {dependentRoot: RootHex; data: routes.validator.ProposerDuty[]};
23
+ export type BlockDutyAtEpoch = {dependentRoot: RootHex; data: routes.validator.ProposerDuty[]};
24
24
  type NotifyBlockProductionFn = (slot: Slot, proposers: BLSPubkey[]) => void;
25
25
 
26
26
  export class BlockDutiesService {
27
27
  /** Notify the block service if it should produce a block. */
28
- private readonly notifyBlockProductionFn: NotifyBlockProductionFn;
28
+ private notifyBlockProductionFn: NotifyBlockProductionFn = () => {};
29
29
  /** Maps an epoch to all *local* proposers in this epoch. Notably, this does not contain
30
30
  proposals for any validators which are not registered locally. */
31
31
  private readonly proposers = new Map<Epoch, BlockDutyAtEpoch>();
@@ -36,11 +36,8 @@ export class BlockDutiesService {
36
36
  private readonly api: ApiClient,
37
37
  private readonly clock: IClock,
38
38
  private readonly validatorStore: ValidatorStore,
39
- private readonly metrics: Metrics | null,
40
- notifyBlockProductionFn: NotifyBlockProductionFn
39
+ private readonly metrics: Metrics | null
41
40
  ) {
42
- this.notifyBlockProductionFn = notifyBlockProductionFn;
43
-
44
41
  // TODO: Instead of polling every CLOCK_SLOT, poll every CLOCK_EPOCH and track re-org events
45
42
  // only then re-fetch the block duties. Make sure most clients (including Lodestar)
46
43
  // properly emit the re-org event
@@ -53,6 +50,14 @@ export class BlockDutiesService {
53
50
  }
54
51
  }
55
52
 
53
+ /**
54
+ * Late-bind the production callback. Allows the duties service to be constructed
55
+ * before the consumer that handles proposal production.
56
+ */
57
+ setNotifyBlockProductionFn(notifyBlockProductionFn: NotifyBlockProductionFn): void {
58
+ this.notifyBlockProductionFn = notifyBlockProductionFn;
59
+ }
60
+
56
61
  /**
57
62
  * Returns the pubkeys of the validators which are assigned to propose in the given slot.
58
63
  *
@@ -75,6 +80,16 @@ export class BlockDutiesService {
75
80
  return Array.from(publicKeys.values());
76
81
  }
77
82
 
83
+ /**
84
+ * Returns the cached `{dependentRoot, data}` entry for `epoch`, or `undefined` if duties
85
+ * for that epoch are not yet known. Consumers can detect a proposer-shuffling change
86
+ * (e.g. after a reorg) by observing a different `dependentRoot` than the one they last
87
+ * read for the same epoch.
88
+ */
89
+ getProposersAtEpoch(epoch: Epoch): BlockDutyAtEpoch | undefined {
90
+ return this.proposers.get(epoch);
91
+ }
92
+
78
93
  removeDutiesForKey(pubkey: PubkeyHex): void {
79
94
  for (const blockDutyAtEpoch of this.proposers.values()) {
80
95
  blockDutyAtEpoch.data = blockDutyAtEpoch.data.filter((proposer) => {
@@ -1,4 +1,5 @@
1
1
  import {ApiClient, routes} from "@lodestar/api";
2
+ import {BeaconConfig} from "@lodestar/config";
2
3
  import {GENESIS_SLOT} from "@lodestar/params";
3
4
  import {Root, RootHex, Slot} from "@lodestar/types";
4
5
  import {Logger, fromHex} from "@lodestar/utils";
@@ -13,6 +14,11 @@ export type HeadEventData = {
13
14
  currentDutyDependentRoot: RootHex;
14
15
  };
15
16
 
17
+ export type ExecutionPayloadAvailableEventData = {
18
+ slot: Slot;
19
+ blockRoot: RootHex;
20
+ };
21
+
16
22
  type RunEveryFn = (event: HeadEventData) => Promise<void>;
17
23
 
18
24
  /**
@@ -24,26 +30,35 @@ export class ChainHeaderTracker {
24
30
  private readonly fns: RunEveryFn[] = [];
25
31
 
26
32
  constructor(
33
+ private readonly config: BeaconConfig,
27
34
  private readonly logger: Logger,
28
35
  private readonly api: ApiClient,
29
36
  private readonly emitter: ValidatorEventEmitter
30
37
  ) {}
31
38
 
32
39
  start(signal: AbortSignal): void {
33
- this.logger.verbose("Subscribing to head event");
40
+ this.logger.verbose("Subscribing to validator events");
41
+
42
+ const topics = [EventType.head];
43
+ // We wait until the gloas fork is configured to avoid breaking
44
+ // connections with pre-gloas beacon nodes
45
+ if (this.config.GLOAS_FORK_EPOCH !== Infinity) {
46
+ topics.push(EventType.executionPayloadAvailable);
47
+ }
48
+
34
49
  this.api.events
35
50
  .eventstream({
36
- topics: [EventType.head],
51
+ topics,
37
52
  signal,
38
- onEvent: this.onHeadUpdate,
53
+ onEvent: this.onEvent,
39
54
  onError: (e) => {
40
- this.logger.error("Failed to receive head event", {}, e);
55
+ this.logger.error("Failed to receive validator event", {}, e);
41
56
  },
42
57
  onClose: () => {
43
- this.logger.verbose("Closed stream for head event", {});
58
+ this.logger.verbose("Closed stream for validator events", {});
44
59
  },
45
60
  })
46
- .catch((e) => this.logger.error("Failed to subscribe to head event", {}, e));
61
+ .catch((e) => this.logger.error("Failed to subscribe to validator events", {}, e));
47
62
  }
48
63
 
49
64
  getCurrentChainHead(slot: Slot): Root | null {
@@ -58,7 +73,7 @@ export class ChainHeaderTracker {
58
73
  this.fns.push(fn);
59
74
  }
60
75
 
61
- private onHeadUpdate = (event: routes.events.BeaconEvent): void => {
76
+ private onEvent = (event: routes.events.BeaconEvent): void => {
62
77
  if (event.type === EventType.head) {
63
78
  const {message} = event;
64
79
  const {slot, block, previousDutyDependentRoot, currentDutyDependentRoot} = message;
@@ -85,5 +100,14 @@ export class ChainHeaderTracker {
85
100
  currentDuty: currentDutyDependentRoot,
86
101
  });
87
102
  }
103
+
104
+ if (event.type === EventType.executionPayloadAvailable) {
105
+ this.emitter.emit(ValidatorEvent.executionPayloadAvailable, event.message);
106
+
107
+ this.logger.verbose("Found execution payload available", {
108
+ slot: event.message.slot,
109
+ blockRoot: event.message.blockRoot,
110
+ });
111
+ }
88
112
  };
89
113
  }
@@ -8,10 +8,20 @@ export enum ValidatorEvent {
8
8
  * This event signals that the node chain has a new head.
9
9
  */
10
10
  chainHead = "chainHead",
11
+ /**
12
+ * This event signals that an execution payload and blobs are available for payload attestation.
13
+ */
14
+ executionPayloadAvailable = "executionPayloadAvailable",
11
15
  }
12
16
 
17
+ export type ExecutionPayloadAvailableEventData = {
18
+ slot: Slot;
19
+ blockRoot: string;
20
+ };
21
+
13
22
  export type ValidatorEvents = {
14
23
  [ValidatorEvent.chainHead]: (head: HeadEventData) => void;
24
+ [ValidatorEvent.executionPayloadAvailable]: (payload: ExecutionPayloadAvailableEventData) => void;
15
25
  };
16
26
 
17
27
  /**
@@ -40,4 +50,25 @@ export class ValidatorEventEmitter extends (EventEmitter as {
40
50
  this.on(ValidatorEvent.chainHead, headListener);
41
51
  });
42
52
  }
53
+
54
+ /**
55
+ * Wait for the first execution payload availability event to come with slot >= provided slot.
56
+ */
57
+ async waitForExecutionPayloadAvailableSlot(slot: Slot): Promise<void> {
58
+ let payloadListener: (payload: ExecutionPayloadAvailableEventData) => void;
59
+
60
+ const onDone = (): void => {
61
+ this.off(ValidatorEvent.executionPayloadAvailable, payloadListener);
62
+ };
63
+
64
+ return new Promise((resolve) => {
65
+ payloadListener = (payload): void => {
66
+ if (payload.slot >= slot) {
67
+ onDone();
68
+ resolve();
69
+ }
70
+ };
71
+ this.on(ValidatorEvent.executionPayloadAvailable, payloadListener);
72
+ });
73
+ }
43
74
  }
@@ -0,0 +1,124 @@
1
+ import {ApiClient} from "@lodestar/api";
2
+ import {ChainForkConfig} from "@lodestar/config";
3
+ import {SLOTS_PER_EPOCH, isForkPostGloas} from "@lodestar/params";
4
+ import {computeEpochAtSlot} from "@lodestar/state-transition";
5
+ import {Epoch, RootHex, Slot, gloas} from "@lodestar/types";
6
+ import {fromHex, toPubkeyHex} from "@lodestar/utils";
7
+ import {Metrics} from "../metrics.js";
8
+ import {IClock, LoggerVc} from "../util/index.js";
9
+ import {BlockDutiesService} from "./blockDuties.js";
10
+ import {ValidatorStore} from "./validatorStore.js";
11
+
12
+ /**
13
+ * Submit a proposer's `SignedProposerPreferences` this many slots before the proposal slot.
14
+ *
15
+ * Earlier submission means more reorg-triggered resubmits (and gossip flood); later
16
+ * submission risks missing the bid-auction window for this proposal slot. The bid for
17
+ * slot S typically arrives at slot S-1, so we want preferences propagated to the network
18
+ * and consumed by builders before then. SLOTS_PER_EPOCH / 4 (8 slots @ 32 SPE, ~96s @ 12s
19
+ * slots) gives ample margin while bounding redundant resubmits.
20
+ */
21
+ const SUBMIT_BEFORE_PROPOSAL_SLOTS = Math.floor(SLOTS_PER_EPOCH / 4);
22
+
23
+ /** Per-epoch tracking of preferences already submitted under the current dependent_root. */
24
+ type SubmittedAtEpoch = {dependentRoot: RootHex; slots: Set<Slot>};
25
+
26
+ /**
27
+ * Signs and submits `SignedProposerPreferences` for any local validator that will propose
28
+ * within the next `SUBMIT_BEFORE_PROPOSAL_SLOTS`. Re-submits automatically when the proposer
29
+ * dependent root for an epoch shifts (e.g. after a reorg) — detected by comparing the cached
30
+ * `dependentRoot` reported by `BlockDutiesService` against the one we last submitted under.
31
+ *
32
+ * No-op pre-gloas.
33
+ */
34
+ export class ProposerPreferencesService {
35
+ private readonly submitted = new Map<Epoch, SubmittedAtEpoch>();
36
+
37
+ constructor(
38
+ private readonly config: ChainForkConfig,
39
+ private readonly logger: LoggerVc,
40
+ private readonly api: ApiClient,
41
+ clock: IClock,
42
+ private readonly validatorStore: ValidatorStore,
43
+ private readonly blockDutiesService: BlockDutiesService,
44
+ _metrics: Metrics | null
45
+ ) {
46
+ clock.runEverySlot(this.runProposerPreferencesTask);
47
+ }
48
+
49
+ private runProposerPreferencesTask = async (slot: Slot): Promise<void> => {
50
+ if (!isForkPostGloas(this.config.getForkName(slot))) {
51
+ return;
52
+ }
53
+
54
+ const currentEpoch = computeEpochAtSlot(slot);
55
+ const batch: gloas.SignedProposerPreferences[] = [];
56
+ // Track which `(submission, slot)` pairs are pending an API submission so we can mark
57
+ // them only after the network call succeeds. Marking before would silently drop a
58
+ // preference on transient API failure (no retry until dependent_root shifts).
59
+ const pending: {submission: SubmittedAtEpoch; slot: Slot}[] = [];
60
+
61
+ for (const epoch of [currentEpoch, currentEpoch + 1]) {
62
+ const dutiesAtEpoch = this.blockDutiesService.getProposersAtEpoch(epoch);
63
+ if (!dutiesAtEpoch) continue;
64
+
65
+ // Reset submission tracking if the dependent root for this epoch has shifted
66
+ // (e.g. due to a reorg). Any previously-submitted preferences are now stale.
67
+ let submission = this.submitted.get(epoch);
68
+ if (submission === undefined || submission.dependentRoot !== dutiesAtEpoch.dependentRoot) {
69
+ if (submission !== undefined) {
70
+ this.logger.info("Proposer-shuffling dependent root shifted; resubmitting preferences", {
71
+ epoch,
72
+ priorDependentRoot: submission.dependentRoot,
73
+ dependentRoot: dutiesAtEpoch.dependentRoot,
74
+ });
75
+ }
76
+ submission = {dependentRoot: dutiesAtEpoch.dependentRoot, slots: new Set()};
77
+ this.submitted.set(epoch, submission);
78
+ }
79
+
80
+ const dependentRootBytes = fromHex(dutiesAtEpoch.dependentRoot);
81
+
82
+ for (const duty of dutiesAtEpoch.data) {
83
+ if (duty.slot <= slot) continue;
84
+ if (duty.slot > slot + SUBMIT_BEFORE_PROPOSAL_SLOTS) continue;
85
+ if (submission.slots.has(duty.slot)) continue;
86
+
87
+ try {
88
+ const pubkeyHex = toPubkeyHex(duty.pubkey);
89
+ const signed = await this.validatorStore.signProposerPreferences(
90
+ duty,
91
+ dependentRootBytes,
92
+ this.validatorStore.getFeeRecipient(pubkeyHex),
93
+ this.validatorStore.getGasLimit(pubkeyHex),
94
+ slot
95
+ );
96
+ batch.push(signed);
97
+ pending.push({submission, slot: duty.slot});
98
+ } catch (e) {
99
+ this.logger.error(
100
+ "Error signing proposer preferences",
101
+ {slot: duty.slot, validatorIndex: duty.validatorIndex},
102
+ e as Error
103
+ );
104
+ }
105
+ }
106
+ }
107
+
108
+ if (batch.length === 0) {
109
+ return;
110
+ }
111
+
112
+ try {
113
+ await this.api.beacon.submitSignedProposerPreferences({signedProposerPreferences: batch});
114
+ // Only mark as submitted after the API call succeeds; a thrown error leaves the
115
+ // slot eligible for retry on the next tick.
116
+ for (const {submission, slot: submittedSlot} of pending) {
117
+ submission.slots.add(submittedSlot);
118
+ }
119
+ this.logger.debug("Submitted signed proposer preferences", {count: batch.length});
120
+ } catch (e) {
121
+ this.logger.error("Error submitting signed proposer preferences", {count: batch.length}, e as Error);
122
+ }
123
+ };
124
+ }
@@ -0,0 +1,131 @@
1
+ import {ApiClient, routes} from "@lodestar/api";
2
+ import {ChainForkConfig} from "@lodestar/config";
3
+ import {isForkPostGloas} from "@lodestar/params";
4
+ import {Slot, gloas} from "@lodestar/types";
5
+ import {prettyBytes, sleep, toRootHex} from "@lodestar/utils";
6
+ import {Metrics} from "../metrics.js";
7
+ import {PubkeyHex} from "../types.js";
8
+ import {IClock, LoggerVc} from "../util/index.js";
9
+ import {ChainHeaderTracker} from "./chainHeaderTracker.js";
10
+ import {ValidatorEventEmitter} from "./emitter.js";
11
+ import {PtcDutiesService} from "./ptcDuties.js";
12
+ import {SyncingStatusTracker} from "./syncingStatusTracker.js";
13
+ import {ValidatorStore} from "./validatorStore.js";
14
+
15
+ /**
16
+ * Service that sets up and handles validator Payload Timeliness Committee duties.
17
+ */
18
+ export class PtcService {
19
+ private readonly dutiesService: PtcDutiesService;
20
+
21
+ constructor(
22
+ private readonly config: ChainForkConfig,
23
+ private readonly logger: LoggerVc,
24
+ private readonly api: ApiClient,
25
+ private readonly clock: IClock,
26
+ private readonly validatorStore: ValidatorStore,
27
+ private readonly emitter: ValidatorEventEmitter,
28
+ chainHeadTracker: ChainHeaderTracker,
29
+ syncingStatusTracker: SyncingStatusTracker,
30
+ private readonly metrics: Metrics | null
31
+ ) {
32
+ this.dutiesService = new PtcDutiesService(
33
+ config,
34
+ logger,
35
+ api,
36
+ clock,
37
+ validatorStore,
38
+ chainHeadTracker,
39
+ syncingStatusTracker,
40
+ metrics
41
+ );
42
+
43
+ clock.runEverySlot(this.runPtcTasks);
44
+ }
45
+
46
+ removeDutiesForKey(pubkey: PubkeyHex): void {
47
+ this.dutiesService.removeDutiesForKey(pubkey);
48
+ }
49
+
50
+ private runPtcTasks = async (slot: Slot, signal: AbortSignal): Promise<void> => {
51
+ const fork = this.config.getForkName(slot);
52
+ if (!isForkPostGloas(fork)) {
53
+ return;
54
+ }
55
+
56
+ const duties = this.dutiesService.getDutiesAtSlot(slot);
57
+ if (duties.length === 0) {
58
+ return;
59
+ }
60
+
61
+ const payloadAttestationDueMs = this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS);
62
+ await Promise.race([
63
+ sleep(payloadAttestationDueMs - this.clock.msFromSlot(slot), signal),
64
+ this.emitter.waitForExecutionPayloadAvailableSlot(slot),
65
+ ]);
66
+
67
+ this.metrics?.ptcStepCallProducePayloadAttestation.observe(
68
+ this.clock.secFromSlot(slot) - payloadAttestationDueMs / 1000
69
+ );
70
+
71
+ try {
72
+ const payloadAttestationData = await this.producePayloadAttestationData(slot);
73
+ await this.signAndPublishPayloadAttestations(slot, payloadAttestationData, duties);
74
+ } catch (e) {
75
+ this.logger.error("Error on PTC routine", {slot}, e as Error);
76
+ }
77
+ };
78
+
79
+ private async producePayloadAttestationData(slot: Slot): Promise<gloas.PayloadAttestationData> {
80
+ return (await this.api.validator.producePayloadAttestationData({slot})).value();
81
+ }
82
+
83
+ private async signAndPublishPayloadAttestations(
84
+ slot: Slot,
85
+ payloadAttestationData: gloas.PayloadAttestationData,
86
+ duties: routes.validator.PtcDuty[]
87
+ ): Promise<void> {
88
+ const payloadAttestationMessages: gloas.PayloadAttestationMessage[] = [];
89
+ const beaconBlockRootHex = toRootHex(payloadAttestationData.beaconBlockRoot);
90
+
91
+ await Promise.all(
92
+ duties.map(async (duty) => {
93
+ const logCtxValidator = {slot, validatorIndex: duty.validatorIndex, beaconBlockRoot: beaconBlockRootHex};
94
+ try {
95
+ payloadAttestationMessages.push(
96
+ await this.validatorStore.signPayloadAttestation(
97
+ duty,
98
+ payloadAttestationData,
99
+ this.clock.getCurrentSlot(),
100
+ this.logger
101
+ )
102
+ );
103
+ this.logger.debug("Signed payload attestation message", logCtxValidator);
104
+ } catch (e) {
105
+ this.metrics?.ptcError.inc({error: "sign"});
106
+ this.logger.error("Error signing payload attestation message", logCtxValidator, e as Error);
107
+ }
108
+ })
109
+ );
110
+
111
+ this.metrics?.ptcStepCallPublishPayloadAttestation.observe(
112
+ this.clock.secFromSlot(slot) -
113
+ this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS) / 1000
114
+ );
115
+
116
+ if (payloadAttestationMessages.length > 0) {
117
+ try {
118
+ (await this.api.beacon.submitPayloadAttestationMessages({payloadAttestationMessages})).assertOk();
119
+ this.logger.info("Published payload attestation messages", {
120
+ slot,
121
+ beaconBlockRoot: prettyBytes(beaconBlockRootHex),
122
+ count: payloadAttestationMessages.length,
123
+ });
124
+ this.metrics?.publishedPayloadAttestations.inc(payloadAttestationMessages.length);
125
+ } catch (e) {
126
+ this.metrics?.ptcError.inc({error: "publish"});
127
+ this.logger.error("Error publishing payload attestation messages", {slot}, e as Error);
128
+ }
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,246 @@
1
+ import {ApiClient, routes} from "@lodestar/api";
2
+ import {ChainForkConfig} from "@lodestar/config";
3
+ import {SLOTS_PER_EPOCH, isForkPostGloas} from "@lodestar/params";
4
+ import {computeEpochAtSlot, isStartSlotOfEpoch} from "@lodestar/state-transition";
5
+ import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types";
6
+ import {toPubkeyHex} from "@lodestar/utils";
7
+ import {Metrics} from "../metrics.js";
8
+ import {PubkeyHex} from "../types.js";
9
+ import {IClock, LoggerVc} from "../util/index.js";
10
+ import {ChainHeaderTracker, HeadEventData} from "./chainHeaderTracker.js";
11
+ import {SyncingStatusTracker} from "./syncingStatusTracker.js";
12
+ import {ValidatorStore} from "./validatorStore.js";
13
+
14
+ /** Only retain `HISTORICAL_DUTIES_EPOCHS` duties prior to the current epoch. */
15
+ const HISTORICAL_DUTIES_EPOCHS = 2;
16
+
17
+ type PtcDutiesAtEpoch = {dependentRoot: RootHex; dutiesByIndex: Map<ValidatorIndex, routes.validator.PtcDuty>};
18
+
19
+ export class PtcDutiesService {
20
+ /** Maps a validator index to its PTC duty for each epoch. */
21
+ private readonly dutiesByIndexByEpoch = new Map<Epoch, PtcDutiesAtEpoch>();
22
+
23
+ constructor(
24
+ private readonly config: ChainForkConfig,
25
+ private readonly logger: LoggerVc,
26
+ private readonly api: ApiClient,
27
+ private readonly clock: IClock,
28
+ private readonly validatorStore: ValidatorStore,
29
+ chainHeadTracker: ChainHeaderTracker,
30
+ syncingStatusTracker: SyncingStatusTracker,
31
+ private readonly metrics: Metrics | null
32
+ ) {
33
+ clock.runEveryEpoch(this.runDutiesTasks);
34
+ chainHeadTracker.runOnNewHead(this.onNewHead);
35
+ syncingStatusTracker.runOnResynced(async (slot) => {
36
+ // Skip on first slot of epoch since tasks are already scheduled.
37
+ if (!isStartSlotOfEpoch(slot)) {
38
+ return this.runDutiesTasks(computeEpochAtSlot(slot));
39
+ }
40
+ });
41
+
42
+ if (metrics) {
43
+ metrics.ptcDutiesCount.addCollect(() => {
44
+ const currentSlot = this.clock.getCurrentSlot();
45
+ let duties = 0;
46
+ let nextDutySlot = null;
47
+ for (const [epoch, ptcDutiesAtEpoch] of this.dutiesByIndexByEpoch) {
48
+ duties += ptcDutiesAtEpoch.dutiesByIndex.size;
49
+
50
+ // Epochs are sorted, stop searching once a next duty slot is found.
51
+ if (epoch < this.clock.currentEpoch || nextDutySlot !== null) continue;
52
+
53
+ for (const duty of ptcDutiesAtEpoch.dutiesByIndex.values()) {
54
+ if (duty.slot > currentSlot && (nextDutySlot === null || duty.slot < nextDutySlot)) {
55
+ nextDutySlot = duty.slot;
56
+ }
57
+ }
58
+ }
59
+ metrics.ptcDutiesCount.set(duties);
60
+ metrics.ptcDutiesEpochCount.set(this.dutiesByIndexByEpoch.size);
61
+ if (nextDutySlot !== null) metrics.ptcDutiesNextSlot.set(nextDutySlot);
62
+ });
63
+ }
64
+ }
65
+
66
+ removeDutiesForKey(pubkey: PubkeyHex): void {
67
+ for (const [epoch, ptcDutiesAtEpoch] of this.dutiesByIndexByEpoch) {
68
+ for (const [validatorIndex, duty] of ptcDutiesAtEpoch.dutiesByIndex) {
69
+ if (toPubkeyHex(duty.pubkey) === pubkey) {
70
+ ptcDutiesAtEpoch.dutiesByIndex.delete(validatorIndex);
71
+ if (ptcDutiesAtEpoch.dutiesByIndex.size === 0) {
72
+ this.dutiesByIndexByEpoch.delete(epoch);
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ /** Returns all PTC duties for the given slot. */
80
+ getDutiesAtSlot(slot: Slot): routes.validator.PtcDuty[] {
81
+ const epoch = computeEpochAtSlot(slot);
82
+ const duties: routes.validator.PtcDuty[] = [];
83
+ const epochDuties = this.dutiesByIndexByEpoch.get(epoch);
84
+ if (epochDuties === undefined) {
85
+ return duties;
86
+ }
87
+
88
+ for (const duty of epochDuties.dutiesByIndex.values()) {
89
+ if (duty.slot === slot) {
90
+ duties.push(duty);
91
+ }
92
+ }
93
+
94
+ return duties;
95
+ }
96
+
97
+ private runDutiesTasks = async (epoch: Epoch): Promise<void> => {
98
+ const nextEpoch = epoch + 1;
99
+ if (!isForkPostGloas(this.config.getForkName(nextEpoch * SLOTS_PER_EPOCH))) {
100
+ return;
101
+ }
102
+
103
+ await Promise.all([
104
+ this.pollPtcDuties(epoch, this.validatorStore.getAllLocalIndices()).catch((e: Error) => {
105
+ this.logger.error("Error on poll PTC duties", {epoch}, e);
106
+ }),
107
+
108
+ this.validatorStore
109
+ .pollValidatorIndices()
110
+ .then((newIndices) => this.pollPtcDuties(epoch, newIndices))
111
+ .catch((e: Error) => {
112
+ this.logger.error("Error on poll indices and PTC duties", {epoch}, e);
113
+ }),
114
+ ]);
115
+
116
+ this.pruneOldDuties(epoch);
117
+ };
118
+
119
+ private async pollPtcDuties(currentEpoch: Epoch, indexArr: ValidatorIndex[]): Promise<void> {
120
+ const nextEpoch = currentEpoch + 1;
121
+
122
+ if (indexArr.length === 0) {
123
+ return;
124
+ }
125
+
126
+ for (const epoch of [currentEpoch, nextEpoch]) {
127
+ await this.pollPtcDutiesForEpoch(epoch, indexArr).catch((e: Error) => {
128
+ this.logger.error("Failed to download PTC duties", {epoch}, e);
129
+ });
130
+ }
131
+ }
132
+
133
+ private async pollPtcDutiesForEpoch(epoch: Epoch, indexArr: ValidatorIndex[]): Promise<void> {
134
+ if (epoch < 0) {
135
+ return;
136
+ }
137
+
138
+ if (!isForkPostGloas(this.config.getForkName(epoch * SLOTS_PER_EPOCH))) {
139
+ return;
140
+ }
141
+
142
+ const res = await this.api.validator.getPtcDuties({epoch, indices: indexArr});
143
+ const ptcDuties = res.value();
144
+ const {dependentRoot} = res.meta();
145
+ const relevantDuties = ptcDuties.filter((duty) => {
146
+ const pubkeyHex = toPubkeyHex(duty.pubkey);
147
+ return this.validatorStore.hasVotingPubkey(pubkeyHex) && this.validatorStore.isDoppelgangerSafe(pubkeyHex);
148
+ });
149
+
150
+ this.logger.debug("Downloaded PTC duties", {epoch, dependentRoot, count: relevantDuties.length});
151
+
152
+ const dutiesAtEpoch = this.dutiesByIndexByEpoch.get(epoch);
153
+ const priorDependentRoot = dutiesAtEpoch?.dependentRoot;
154
+ const dependentRootChanged = priorDependentRoot !== undefined && priorDependentRoot !== dependentRoot;
155
+
156
+ if (!priorDependentRoot || dependentRootChanged) {
157
+ const dutiesByIndex = new Map<ValidatorIndex, routes.validator.PtcDuty>();
158
+ for (const duty of relevantDuties) {
159
+ dutiesByIndex.set(duty.validatorIndex, duty);
160
+ }
161
+ this.dutiesByIndexByEpoch.set(epoch, {dependentRoot, dutiesByIndex});
162
+
163
+ if (priorDependentRoot && dependentRootChanged) {
164
+ this.metrics?.ptcDutiesReorg.inc();
165
+ this.logger.warn("PTC duties re-org. This may happen from time to time", {
166
+ priorDependentRoot,
167
+ dependentRoot,
168
+ epoch,
169
+ });
170
+ }
171
+ } else {
172
+ const existingDuties = dutiesAtEpoch.dutiesByIndex;
173
+ const existingDutiesCount = existingDuties.size;
174
+ const discoveredNewDuties = relevantDuties.length > existingDutiesCount;
175
+
176
+ if (discoveredNewDuties) {
177
+ for (const duty of relevantDuties) {
178
+ if (!existingDuties.has(duty.validatorIndex)) {
179
+ existingDuties.set(duty.validatorIndex, duty);
180
+ }
181
+ }
182
+
183
+ this.logger.debug("Discovered new PTC duties", {
184
+ epoch,
185
+ dependentRoot,
186
+ count: relevantDuties.length - existingDutiesCount,
187
+ });
188
+ }
189
+ }
190
+ }
191
+
192
+ private onNewHead = async ({
193
+ slot,
194
+ previousDutyDependentRoot,
195
+ currentDutyDependentRoot,
196
+ }: HeadEventData): Promise<void> => {
197
+ const currentEpoch = computeEpochAtSlot(slot);
198
+ const nextEpoch = currentEpoch + 1;
199
+
200
+ const nextEpochDependentRoot = this.dutiesByIndexByEpoch.get(nextEpoch)?.dependentRoot;
201
+ if (nextEpochDependentRoot && currentDutyDependentRoot !== nextEpochDependentRoot) {
202
+ this.logger.warn("Potential next epoch PTC duties reorg", {
203
+ slot,
204
+ dutyEpoch: nextEpoch,
205
+ priorDependentRoot: nextEpochDependentRoot,
206
+ newDependentRoot: currentDutyDependentRoot,
207
+ });
208
+ await this.handlePtcDutiesReorg(nextEpoch, slot, nextEpochDependentRoot, currentDutyDependentRoot);
209
+ }
210
+
211
+ const currentEpochDependentRoot = this.dutiesByIndexByEpoch.get(currentEpoch)?.dependentRoot;
212
+ if (currentEpochDependentRoot && currentEpochDependentRoot !== previousDutyDependentRoot) {
213
+ this.logger.warn("Potential current epoch PTC duties reorg", {
214
+ slot,
215
+ dutyEpoch: currentEpoch,
216
+ priorDependentRoot: currentEpochDependentRoot,
217
+ newDependentRoot: previousDutyDependentRoot,
218
+ });
219
+ await this.handlePtcDutiesReorg(currentEpoch, slot, currentEpochDependentRoot, previousDutyDependentRoot);
220
+ }
221
+ };
222
+
223
+ private async handlePtcDutiesReorg(
224
+ dutyEpoch: Epoch,
225
+ slot: Slot,
226
+ oldDependentRoot: RootHex,
227
+ newDependentRoot: RootHex
228
+ ): Promise<void> {
229
+ this.metrics?.ptcDutiesReorg.inc();
230
+ const logContext = {dutyEpoch, slot, oldDependentRoot, newDependentRoot};
231
+ this.logger.debug("Redownload PTC duties", logContext);
232
+
233
+ await this.pollPtcDutiesForEpoch(dutyEpoch, this.validatorStore.getAllLocalIndices()).catch((e: Error) => {
234
+ this.logger.error("Failed to redownload PTC duties when reorg happens", logContext, e);
235
+ });
236
+ }
237
+
238
+ /** Run once per epoch to prune duties map. */
239
+ private pruneOldDuties(currentEpoch: Epoch): void {
240
+ for (const epoch of this.dutiesByIndexByEpoch.keys()) {
241
+ if (epoch + HISTORICAL_DUTIES_EPOCHS < currentEpoch) {
242
+ this.dutiesByIndexByEpoch.delete(epoch);
243
+ }
244
+ }
245
+ }
246
+ }