@lodestar/validator 1.35.0-dev.83de5b8dea → 1.35.0-dev.8689cc3545

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 (115) hide show
  1. package/lib/buckets.d.ts.map +1 -0
  2. package/lib/defaults.d.ts.map +1 -0
  3. package/lib/genesis.d.ts.map +1 -0
  4. package/lib/index.d.ts.map +1 -0
  5. package/lib/metrics.d.ts.map +1 -0
  6. package/lib/repositories/index.d.ts.map +1 -0
  7. package/lib/repositories/metaDataRepository.d.ts.map +1 -0
  8. package/lib/services/attestation.d.ts.map +1 -0
  9. package/lib/services/attestationDuties.d.ts.map +1 -0
  10. package/lib/services/block.d.ts.map +1 -0
  11. package/lib/services/blockDuties.d.ts.map +1 -0
  12. package/lib/services/chainHeaderTracker.d.ts.map +1 -0
  13. package/lib/services/doppelgangerService.d.ts.map +1 -0
  14. package/lib/services/emitter.d.ts.map +1 -0
  15. package/lib/services/externalSignerSync.d.ts.map +1 -0
  16. package/lib/services/indices.d.ts.map +1 -0
  17. package/lib/services/prepareBeaconProposer.d.ts.map +1 -0
  18. package/lib/services/syncCommittee.d.ts.map +1 -0
  19. package/lib/services/syncCommitteeDuties.d.ts.map +1 -0
  20. package/lib/services/syncingStatusTracker.d.ts.map +1 -0
  21. package/lib/services/utils.d.ts.map +1 -0
  22. package/lib/services/validatorStore.d.ts.map +1 -0
  23. package/lib/slashingProtection/attestation/attestationByTargetRepository.d.ts.map +1 -0
  24. package/lib/slashingProtection/attestation/attestationLowerBoundRepository.d.ts.map +1 -0
  25. package/lib/slashingProtection/attestation/errors.d.ts.map +1 -0
  26. package/lib/slashingProtection/attestation/index.d.ts.map +1 -0
  27. package/lib/slashingProtection/block/blockBySlotRepository.d.ts.map +1 -0
  28. package/lib/slashingProtection/block/errors.d.ts.map +1 -0
  29. package/lib/slashingProtection/block/index.d.ts.map +1 -0
  30. package/lib/slashingProtection/index.d.ts.map +1 -0
  31. package/lib/slashingProtection/interchange/errors.d.ts.map +1 -0
  32. package/lib/slashingProtection/interchange/formats/completeV4.d.ts.map +1 -0
  33. package/lib/slashingProtection/interchange/formats/index.d.ts.map +1 -0
  34. package/lib/slashingProtection/interchange/formats/v5.d.ts.map +1 -0
  35. package/lib/slashingProtection/interchange/index.d.ts.map +1 -0
  36. package/lib/slashingProtection/interchange/parseInterchange.d.ts.map +1 -0
  37. package/lib/slashingProtection/interchange/serializeInterchange.d.ts.map +1 -0
  38. package/lib/slashingProtection/interchange/types.d.ts.map +1 -0
  39. package/lib/slashingProtection/interface.d.ts.map +1 -0
  40. package/lib/slashingProtection/minMaxSurround/distanceStoreRepository.d.ts.map +1 -0
  41. package/lib/slashingProtection/minMaxSurround/errors.d.ts.map +1 -0
  42. package/lib/slashingProtection/minMaxSurround/index.d.ts.map +1 -0
  43. package/lib/slashingProtection/minMaxSurround/interface.d.ts.map +1 -0
  44. package/lib/slashingProtection/minMaxSurround/minMaxSurround.d.ts.map +1 -0
  45. package/lib/slashingProtection/types.d.ts.map +1 -0
  46. package/lib/slashingProtection/utils.d.ts.map +1 -0
  47. package/lib/types.d.ts.map +1 -0
  48. package/lib/util/batch.d.ts.map +1 -0
  49. package/lib/util/clock.d.ts.map +1 -0
  50. package/lib/util/difference.d.ts.map +1 -0
  51. package/lib/util/externalSignerClient.d.ts.map +1 -0
  52. package/lib/util/format.d.ts.map +1 -0
  53. package/lib/util/index.d.ts.map +1 -0
  54. package/lib/util/logger.d.ts.map +1 -0
  55. package/lib/util/params.d.ts.map +1 -0
  56. package/lib/util/url.d.ts.map +1 -0
  57. package/lib/validator.d.ts.map +1 -0
  58. package/package.json +13 -15
  59. package/src/buckets.ts +30 -0
  60. package/src/defaults.ts +8 -0
  61. package/src/genesis.ts +19 -0
  62. package/src/index.ts +22 -0
  63. package/src/metrics.ts +417 -0
  64. package/src/repositories/index.ts +1 -0
  65. package/src/repositories/metaDataRepository.ts +42 -0
  66. package/src/services/attestation.ts +349 -0
  67. package/src/services/attestationDuties.ts +405 -0
  68. package/src/services/block.ts +261 -0
  69. package/src/services/blockDuties.ts +215 -0
  70. package/src/services/chainHeaderTracker.ts +89 -0
  71. package/src/services/doppelgangerService.ts +286 -0
  72. package/src/services/emitter.ts +43 -0
  73. package/src/services/externalSignerSync.ts +81 -0
  74. package/src/services/indices.ts +165 -0
  75. package/src/services/prepareBeaconProposer.ts +119 -0
  76. package/src/services/syncCommittee.ts +317 -0
  77. package/src/services/syncCommitteeDuties.ts +337 -0
  78. package/src/services/syncingStatusTracker.ts +74 -0
  79. package/src/services/utils.ts +58 -0
  80. package/src/services/validatorStore.ts +830 -0
  81. package/src/slashingProtection/attestation/attestationByTargetRepository.ts +77 -0
  82. package/src/slashingProtection/attestation/attestationLowerBoundRepository.ts +44 -0
  83. package/src/slashingProtection/attestation/errors.ts +66 -0
  84. package/src/slashingProtection/attestation/index.ts +171 -0
  85. package/src/slashingProtection/block/blockBySlotRepository.ts +78 -0
  86. package/src/slashingProtection/block/errors.ts +28 -0
  87. package/src/slashingProtection/block/index.ts +94 -0
  88. package/src/slashingProtection/index.ts +95 -0
  89. package/src/slashingProtection/interchange/errors.ts +15 -0
  90. package/src/slashingProtection/interchange/formats/completeV4.ts +125 -0
  91. package/src/slashingProtection/interchange/formats/index.ts +7 -0
  92. package/src/slashingProtection/interchange/formats/v5.ts +120 -0
  93. package/src/slashingProtection/interchange/index.ts +5 -0
  94. package/src/slashingProtection/interchange/parseInterchange.ts +55 -0
  95. package/src/slashingProtection/interchange/serializeInterchange.ts +35 -0
  96. package/src/slashingProtection/interchange/types.ts +18 -0
  97. package/src/slashingProtection/interface.ts +28 -0
  98. package/src/slashingProtection/minMaxSurround/distanceStoreRepository.ts +57 -0
  99. package/src/slashingProtection/minMaxSurround/errors.ts +27 -0
  100. package/src/slashingProtection/minMaxSurround/index.ts +4 -0
  101. package/src/slashingProtection/minMaxSurround/interface.ts +23 -0
  102. package/src/slashingProtection/minMaxSurround/minMaxSurround.ts +104 -0
  103. package/src/slashingProtection/types.ts +12 -0
  104. package/src/slashingProtection/utils.ts +42 -0
  105. package/src/types.ts +31 -0
  106. package/src/util/batch.ts +15 -0
  107. package/src/util/clock.ts +164 -0
  108. package/src/util/difference.ts +10 -0
  109. package/src/util/externalSignerClient.ts +277 -0
  110. package/src/util/format.ts +3 -0
  111. package/src/util/index.ts +6 -0
  112. package/src/util/logger.ts +51 -0
  113. package/src/util/params.ts +313 -0
  114. package/src/util/url.ts +16 -0
  115. package/src/validator.ts +418 -0
@@ -0,0 +1,81 @@
1
+ import {PublicKey} from "@chainsafe/blst";
2
+ import {ChainForkConfig} from "@lodestar/config";
3
+ import {SLOTS_PER_EPOCH} from "@lodestar/params";
4
+ import {fromHex, toPrintableUrl} from "@lodestar/utils";
5
+ import {externalSignerGetKeys} from "../util/externalSignerClient.js";
6
+ import {LoggerVc} from "../util/index.js";
7
+ import {SignerType, ValidatorStore} from "./validatorStore.js";
8
+
9
+ export type ExternalSignerOptions = {
10
+ url?: string;
11
+ fetch?: boolean;
12
+ fetchInterval?: number;
13
+ };
14
+
15
+ /**
16
+ * This service is responsible for keeping the keys managed by the connected
17
+ * external signer and the validator client in sync by adding newly discovered keys
18
+ * and removing no longer present keys on external signer from the validator store.
19
+ */
20
+ export function pollExternalSignerPubkeys(
21
+ config: ChainForkConfig,
22
+ logger: LoggerVc,
23
+ signal: AbortSignal,
24
+ validatorStore: ValidatorStore,
25
+ opts?: ExternalSignerOptions
26
+ ): void {
27
+ const externalSigner = opts ?? {};
28
+
29
+ if (!externalSigner.url || !externalSigner.fetch) {
30
+ return; // Disabled
31
+ }
32
+
33
+ async function fetchExternalSignerPubkeys(): Promise<void> {
34
+ // External signer URL is already validated earlier
35
+ const externalSignerUrl = externalSigner.url as string;
36
+ const printableUrl = toPrintableUrl(externalSignerUrl);
37
+
38
+ try {
39
+ logger.debug("Fetching public keys from external signer", {url: printableUrl});
40
+ const externalPubkeys = await externalSignerGetKeys(externalSignerUrl);
41
+ assertValidPubkeysHex(externalPubkeys);
42
+ logger.debug("Received public keys from external signer", {url: printableUrl, count: externalPubkeys.length});
43
+
44
+ const localPubkeys = validatorStore.getRemoteSignerPubkeys(externalSignerUrl);
45
+ logger.debug("Local public keys stored for external signer", {url: printableUrl, count: localPubkeys.length});
46
+
47
+ const localPubkeysSet = new Set(localPubkeys);
48
+ for (const pubkey of externalPubkeys) {
49
+ if (!localPubkeysSet.has(pubkey)) {
50
+ await validatorStore.addSigner({type: SignerType.Remote, pubkey, url: externalSignerUrl});
51
+ logger.info("Added remote signer", {pubkey, url: printableUrl});
52
+ }
53
+ }
54
+
55
+ const externalPubkeysSet = new Set(externalPubkeys);
56
+ for (const pubkey of localPubkeys) {
57
+ if (!externalPubkeysSet.has(pubkey)) {
58
+ validatorStore.removeSigner(pubkey);
59
+ logger.info("Removed remote signer", {pubkey, url: printableUrl});
60
+ }
61
+ }
62
+ } catch (e) {
63
+ logger.error("Failed to fetch public keys from external signer", {url: printableUrl}, e as Error);
64
+ }
65
+ }
66
+
67
+ const interval = setInterval(
68
+ fetchExternalSignerPubkeys,
69
+ externalSigner.fetchInterval ??
70
+ // Once per epoch by default
71
+ SLOTS_PER_EPOCH * config.SECONDS_PER_SLOT * 1000
72
+ );
73
+ signal.addEventListener("abort", () => clearInterval(interval), {once: true});
74
+ }
75
+
76
+ function assertValidPubkeysHex(pubkeysHex: string[]): void {
77
+ for (const pubkeyHex of pubkeysHex) {
78
+ const pubkeyBytes = fromHex(pubkeyHex);
79
+ PublicKey.fromBytes(pubkeyBytes, true);
80
+ }
81
+ }
@@ -0,0 +1,165 @@
1
+ import {ApiClient, routes} from "@lodestar/api";
2
+ import {ValidatorIndex} from "@lodestar/types";
3
+ import {Logger, MapDef, toPubkeyHex} from "@lodestar/utils";
4
+ import {Metrics} from "../metrics.js";
5
+ import {batchItems} from "../util/index.js";
6
+
7
+ /**
8
+ * This is to prevent the "Request body is too large" issue for http post.
9
+ * Typical servers accept up to 1MB (2 ** 20 bytes) of request body, for example fastify and nginx.
10
+ * A hex encoded public key with "0x"-prefix has a size of 98 bytes + 2 bytes to account for commas
11
+ * and other JSON padding. `Math.floor(2 ** 20 / 100) == 10485`, we can send up to ~10k keys per request.
12
+ */
13
+ const PUBKEYS_PER_REQUEST = 10_000;
14
+
15
+ // To assist with readability
16
+ type PubkeyHex = string;
17
+
18
+ // To assist with logging statuses, we only log the statuses that are not active_exiting or withdrawal_possible
19
+ type SimpleValidatorStatus = "pending" | "active" | "exited" | "withdrawn";
20
+
21
+ const statusToSimpleStatusMapping = (status: routes.beacon.ValidatorStatus): SimpleValidatorStatus => {
22
+ switch (status) {
23
+ case "active_exiting":
24
+ case "active_slashed":
25
+ case "active_ongoing":
26
+ return "active";
27
+
28
+ case "withdrawal_possible":
29
+ case "exited_slashed":
30
+ case "exited_unslashed":
31
+ return "exited";
32
+
33
+ case "pending_initialized":
34
+ case "pending_queued":
35
+ return "pending";
36
+
37
+ case "withdrawal_done":
38
+ return "withdrawn";
39
+ }
40
+ };
41
+
42
+ export class IndicesService {
43
+ readonly index2pubkey = new Map<ValidatorIndex, PubkeyHex>();
44
+ /** Indexed by pubkey in hex 0x prefixed */
45
+ readonly pubkey2index = new Map<PubkeyHex, ValidatorIndex>();
46
+ // Request indices once
47
+ private pollValidatorIndicesPromise: Promise<ValidatorIndex[]> | null = null;
48
+
49
+ constructor(
50
+ private readonly logger: Logger,
51
+ private readonly api: ApiClient,
52
+ private readonly metrics: Metrics | null
53
+ ) {
54
+ if (metrics) {
55
+ metrics.indices.addCollect(() => metrics.indices.set(this.index2pubkey.size));
56
+ }
57
+ }
58
+
59
+ get indexCount(): number {
60
+ return this.index2pubkey.size;
61
+ }
62
+
63
+ /** Returns the validator index for a given validator pubkey */
64
+ getValidatorIndex(pubKey: PubkeyHex): ValidatorIndex | undefined {
65
+ return this.pubkey2index.get(pubKey);
66
+ }
67
+
68
+ /** Return all known indices from the validatorStore pubkeys */
69
+ getAllLocalIndices(): ValidatorIndex[] {
70
+ return Array.from(this.index2pubkey.keys());
71
+ }
72
+
73
+ /** Return true if `index` is active part of this validator client */
74
+ hasValidatorIndex(index: ValidatorIndex): boolean {
75
+ return this.index2pubkey.has(index);
76
+ }
77
+
78
+ async pollValidatorIndices(pubkeysHex: PubkeyHex[]): Promise<ValidatorIndex[]> {
79
+ // Ensures pollValidatorIndicesInternal() is not called more than once at the same time.
80
+ // AttestationDutiesService, SyncCommitteeDutiesService and DoppelgangerService will call this function at the same time, so this will
81
+ // cache the promise and return it to the second caller, preventing calling the API twice for the same data.
82
+ if (this.pollValidatorIndicesPromise) {
83
+ return this.pollValidatorIndicesPromise;
84
+ }
85
+
86
+ this.pollValidatorIndicesPromise = this.pollValidatorIndicesInternal(pubkeysHex).finally(() => {
87
+ // Once the pollValidatorIndicesInternal() resolves or rejects null the cached promise so it can be called again.
88
+ this.pollValidatorIndicesPromise = null;
89
+ });
90
+ return this.pollValidatorIndicesPromise;
91
+ }
92
+
93
+ removeForKey(pubkey: PubkeyHex): boolean {
94
+ for (const [index, value] of this.index2pubkey) {
95
+ if (value === pubkey) {
96
+ this.index2pubkey.delete(index);
97
+ }
98
+ }
99
+ return this.pubkey2index.delete(pubkey);
100
+ }
101
+
102
+ /** Iterate through all the voting pubkeys in the `ValidatorStore` and attempt to learn any unknown
103
+ validator indices. Returns the new discovered indexes */
104
+ private async pollValidatorIndicesInternal(pubkeysHex: PubkeyHex[]): Promise<ValidatorIndex[]> {
105
+ const pubkeysHexToDiscover = pubkeysHex.filter((pubkey) => !this.pubkey2index.has(pubkey));
106
+
107
+ if (pubkeysHexToDiscover.length === 0) {
108
+ return [];
109
+ }
110
+
111
+ // Query the remote BN to resolve a pubkey to a validator index.
112
+ // support up to 10k pubkeys per poll
113
+ const pubkeysHexBatches = batchItems(pubkeysHexToDiscover, {batchSize: PUBKEYS_PER_REQUEST});
114
+
115
+ const newIndices: number[] = [];
116
+ for (const pubkeysHexBatch of pubkeysHexBatches) {
117
+ const validatorIndicesArr = await this.fetchValidatorIndices(pubkeysHexBatch);
118
+ newIndices.push(...validatorIndicesArr);
119
+ }
120
+
121
+ this.metrics?.discoveredIndices.inc(newIndices.length);
122
+
123
+ return newIndices;
124
+ }
125
+
126
+ private async fetchValidatorIndices(pubkeysHex: string[]): Promise<ValidatorIndex[]> {
127
+ const validators = (await this.api.beacon.postStateValidators({stateId: "head", validatorIds: pubkeysHex})).value();
128
+
129
+ const newIndices = [];
130
+
131
+ const allValidatorStatuses = new MapDef<SimpleValidatorStatus, number>(() => 0);
132
+
133
+ for (const validator of validators) {
134
+ // Group all validators by status
135
+ const status = statusToSimpleStatusMapping(validator.status);
136
+ allValidatorStatuses.set(status, allValidatorStatuses.getOrDefault(status) + 1);
137
+
138
+ const pubkeyHex = toPubkeyHex(validator.validator.pubkey);
139
+ if (!this.pubkey2index.has(pubkeyHex)) {
140
+ this.logger.info("Validator seen on beacon chain", {
141
+ validatorIndex: validator.index,
142
+ pubKey: pubkeyHex,
143
+ });
144
+ this.pubkey2index.set(pubkeyHex, validator.index);
145
+ this.index2pubkey.set(validator.index, pubkeyHex);
146
+ newIndices.push(validator.index);
147
+ }
148
+ }
149
+
150
+ // The number of validators that are not in the beacon chain
151
+ const pendingCount = pubkeysHex.length - validators.length;
152
+
153
+ allValidatorStatuses.set("pending", allValidatorStatuses.getOrDefault("pending") + pendingCount);
154
+
155
+ // Retrieve the number of validators for each status
156
+ const statuses = Object.fromEntries(Array.from(allValidatorStatuses.entries()).filter((entry) => entry[1] > 0));
157
+
158
+ // The total number of validators
159
+ const total = pubkeysHex.length;
160
+
161
+ this.logger.info("Validator statuses", {...statuses, total});
162
+
163
+ return newIndices;
164
+ }
165
+ }
@@ -0,0 +1,119 @@
1
+ import {ApiClient, routes} from "@lodestar/api";
2
+ import {BeaconConfig} from "@lodestar/config";
3
+ import {GENESIS_EPOCH, SLOTS_PER_EPOCH} from "@lodestar/params";
4
+ import {Epoch, bellatrix} from "@lodestar/types";
5
+ import {Metrics} from "../metrics.js";
6
+ import {IClock, LoggerVc, batchItems} from "../util/index.js";
7
+ import {ValidatorStore} from "./validatorStore.js";
8
+
9
+ const REGISTRATION_CHUNK_SIZE = 512;
10
+ /**
11
+ * This service is responsible for registering validators to beacon node with the
12
+ * proposer data (currently `feeRecipient`) so that it can issue advance fcUs to
13
+ * the engine for building execution payload with transactions.
14
+ *
15
+ * This needs to be done every epoch because the BN will cache it at most for
16
+ * two epochs.
17
+ */
18
+ export function pollPrepareBeaconProposer(
19
+ config: BeaconConfig,
20
+ logger: LoggerVc,
21
+ api: ApiClient,
22
+ clock: IClock,
23
+ validatorStore: ValidatorStore,
24
+ _metrics: Metrics | null
25
+ ): void {
26
+ async function prepareBeaconProposer(epoch: Epoch): Promise<void> {
27
+ // Before bellatrix we don't need to update this data on bn/builder
28
+ if (epoch < config.BELLATRIX_FORK_EPOCH - 1) return;
29
+
30
+ // prepareBeaconProposer is not as time sensitive as attesting.
31
+ // Poll indices first, then call api.validator.prepareBeaconProposer once
32
+ await validatorStore.pollValidatorIndices().catch((e: Error) => {
33
+ logger.error("Error on pollValidatorIndices for prepareBeaconProposer", {epoch}, e);
34
+ });
35
+
36
+ const indicesChunks = batchItems(validatorStore.getAllLocalIndices(), {batchSize: REGISTRATION_CHUNK_SIZE});
37
+
38
+ for (const indices of indicesChunks) {
39
+ try {
40
+ const proposers = indices.map(
41
+ (index): routes.validator.ProposerPreparationData => ({
42
+ validatorIndex: index,
43
+ feeRecipient: validatorStore.getFeeRecipientByIndex(index),
44
+ })
45
+ );
46
+ (await api.validator.prepareBeaconProposer({proposers})).assertOk();
47
+ logger.debug("Registered proposers with beacon node", {epoch, count: proposers.length});
48
+ } catch (e) {
49
+ logger.error("Failed to register proposers with beacon node", {epoch}, e as Error);
50
+ }
51
+ }
52
+ }
53
+
54
+ clock.runEveryEpoch(prepareBeaconProposer);
55
+ }
56
+
57
+ /**
58
+ * This service is responsible for registering validators with the mev builder as they
59
+ * might prepare and keep ready the execution payloads of just registered validators.
60
+ *
61
+ * This needs to be done every epoch because the builder(s) will cache it at most for
62
+ * two epochs.
63
+ */
64
+ export function pollBuilderValidatorRegistration(
65
+ config: BeaconConfig,
66
+ logger: LoggerVc,
67
+ api: ApiClient,
68
+ clock: IClock,
69
+ validatorStore: ValidatorStore,
70
+ _metrics: Metrics | null
71
+ ): void {
72
+ async function registerValidator(epoch: Epoch): Promise<void> {
73
+ // Don't send validator registrations pre-genesis as mev-boost-relay will reject
74
+ // those registrations anyways if timestamp is before genesis time and we wanna
75
+ // avoid caching and re-sending them in subsequent requests
76
+ if (epoch < GENESIS_EPOCH) return;
77
+
78
+ // Before bellatrix we don't need to update this data on bn/builder
79
+ if (epoch < config.BELLATRIX_FORK_EPOCH - 1) return;
80
+ const slot = epoch * SLOTS_PER_EPOCH;
81
+
82
+ // registerValidator is not as time sensitive as attesting.
83
+ // Poll indices first, then call api.validator.registerValidator once
84
+ await validatorStore.pollValidatorIndices().catch((e: Error) => {
85
+ logger.error("Error on pollValidatorIndices for registerValidator", {epoch}, e);
86
+ });
87
+ const pubkeyHexes = validatorStore
88
+ .getAllLocalIndices()
89
+ .map((index) => validatorStore.getPubkeyOfIndex(index))
90
+ .filter(
91
+ (pubkeyHex): pubkeyHex is string =>
92
+ pubkeyHex !== undefined &&
93
+ validatorStore.getBuilderSelectionParams(pubkeyHex).selection !==
94
+ routes.validator.BuilderSelection.ExecutionOnly
95
+ );
96
+
97
+ if (pubkeyHexes.length > 0) {
98
+ const pubkeyHexesChunks = batchItems(pubkeyHexes, {batchSize: REGISTRATION_CHUNK_SIZE});
99
+
100
+ for (const pubkeyHexes of pubkeyHexesChunks) {
101
+ try {
102
+ const registrations = await Promise.all(
103
+ pubkeyHexes.map((pubkeyHex): Promise<bellatrix.SignedValidatorRegistrationV1> => {
104
+ const feeRecipient = validatorStore.getFeeRecipient(pubkeyHex);
105
+ const gasLimit = validatorStore.getGasLimit(pubkeyHex);
106
+ return validatorStore.getValidatorRegistration(pubkeyHex, {feeRecipient, gasLimit}, slot);
107
+ })
108
+ );
109
+ (await api.validator.registerValidator({registrations})).assertOk();
110
+ logger.info("Published validator registrations to builder", {epoch, count: registrations.length});
111
+ } catch (e) {
112
+ logger.error("Failed to publish validator registrations to builder", {epoch}, e as Error);
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ clock.runEveryEpoch(registerValidator);
119
+ }
@@ -0,0 +1,317 @@
1
+ import {ApiClient, routes} from "@lodestar/api";
2
+ import {ChainForkConfig} from "@lodestar/config";
3
+ import {computeEpochAtSlot, isSyncCommitteeAggregator} from "@lodestar/state-transition";
4
+ import {BLSSignature, CommitteeIndex, Root, Slot, altair} from "@lodestar/types";
5
+ import {sleep} 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 {SyncCommitteeDutiesService, SyncDutyAndProofs} from "./syncCommitteeDuties.js";
12
+ import {SyncingStatusTracker} from "./syncingStatusTracker.js";
13
+ import {SubcommitteeDuty, groupSyncDutiesBySubcommitteeIndex} from "./utils.js";
14
+ import {ValidatorStore} from "./validatorStore.js";
15
+
16
+ export type SyncCommitteeServiceOpts = {
17
+ scAfterBlockDelaySlotFraction?: number;
18
+ distributedAggregationSelection?: boolean;
19
+ };
20
+
21
+ /**
22
+ * Service that sets up and handles validator sync duties.
23
+ */
24
+ export class SyncCommitteeService {
25
+ private readonly dutiesService: SyncCommitteeDutiesService;
26
+
27
+ constructor(
28
+ private readonly config: ChainForkConfig,
29
+ private readonly logger: LoggerVc,
30
+ private readonly api: ApiClient,
31
+ private readonly clock: IClock,
32
+ private readonly validatorStore: ValidatorStore,
33
+ private readonly emitter: ValidatorEventEmitter,
34
+ private readonly chainHeaderTracker: ChainHeaderTracker,
35
+ readonly syncingStatusTracker: SyncingStatusTracker,
36
+ private readonly metrics: Metrics | null,
37
+ private readonly opts?: SyncCommitteeServiceOpts
38
+ ) {
39
+ this.dutiesService = new SyncCommitteeDutiesService(
40
+ config,
41
+ logger,
42
+ api,
43
+ clock,
44
+ validatorStore,
45
+ syncingStatusTracker,
46
+ metrics,
47
+ {
48
+ distributedAggregationSelection: opts?.distributedAggregationSelection,
49
+ }
50
+ );
51
+
52
+ // At most every slot, check existing duties from SyncCommitteeDutiesService and run tasks
53
+ clock.runEverySlot(this.runSyncCommitteeTasks);
54
+ }
55
+
56
+ removeDutiesForKey(pubkey: PubkeyHex): void {
57
+ this.dutiesService.removeDutiesForKey(pubkey);
58
+ }
59
+
60
+ private runSyncCommitteeTasks = async (slot: Slot, signal: AbortSignal): Promise<void> => {
61
+ try {
62
+ // Before altair fork no need to check duties
63
+ if (computeEpochAtSlot(slot) < this.config.ALTAIR_FORK_EPOCH) {
64
+ return;
65
+ }
66
+
67
+ // Fetch info first so a potential delay is absorbed by the sleep() below
68
+ const dutiesAtSlot = await this.dutiesService.getDutiesAtSlot(slot);
69
+ if (dutiesAtSlot.length === 0) {
70
+ return;
71
+ }
72
+
73
+ if (this.opts?.distributedAggregationSelection) {
74
+ // Validator in distributed cluster only has a key share, not the full private key.
75
+ // The partial selection proofs must be exchanged for combined selection proofs by
76
+ // calling submitSyncCommitteeSelections on the distributed validator middleware client.
77
+ // This will run in parallel to other sync committee tasks but must be finished before starting
78
+ // sync committee contributions as it is required to correctly determine if validator is aggregator
79
+ // and to produce a ContributionAndProof that can be threshold aggregated by the middleware client.
80
+ this.runDistributedAggregationSelectionTasks(dutiesAtSlot, slot, signal).catch((e) =>
81
+ this.logger.error("Error on sync committee aggregation selection", {slot}, e)
82
+ );
83
+ }
84
+
85
+ // unlike Attestation, SyncCommitteeSignature could be published asap
86
+ // especially with lodestar, it's very busy at 1/3 of slot
87
+ // see https://github.com/ChainSafe/lodestar/issues/4608
88
+ await Promise.race([sleep(this.clock.msToSlot(slot + 1 / 3), signal), this.emitter.waitForBlockSlot(slot)]);
89
+ this.metrics?.syncCommitteeStepCallProduceMessage.observe(this.clock.secFromSlot(slot + 1 / 3));
90
+
91
+ // Step 1. Download, sign and publish an `SyncCommitteeMessage` for each validator.
92
+ // Differs from AttestationService, `SyncCommitteeMessage` are equal for all
93
+ const beaconBlockRoot = await this.produceAndPublishSyncCommittees(slot, dutiesAtSlot);
94
+
95
+ // Step 2. If an attestation was produced, make an aggregate.
96
+ // First, wait until the `aggregation_production_instant` (2/3rds of the way though the slot)
97
+ await sleep(this.clock.msToSlot(slot + 2 / 3), signal);
98
+ this.metrics?.syncCommitteeStepCallProduceAggregate.observe(this.clock.secFromSlot(slot + 2 / 3));
99
+
100
+ // await for all so if the Beacon node is overloaded it auto-throttles
101
+ // TODO: This approach is conservative to reduce the node's load, review
102
+ const dutiesBySubcommitteeIndex = groupSyncDutiesBySubcommitteeIndex(dutiesAtSlot);
103
+ await Promise.all(
104
+ Array.from(dutiesBySubcommitteeIndex.entries()).map(async ([subcommitteeIndex, duties]) => {
105
+ if (duties.length === 0) return;
106
+ // Then download, sign and publish a `SignedAggregateAndProof` for each
107
+ // validator that is elected to aggregate for this `slot` and `subcommitteeIndex`.
108
+ await this.produceAndPublishAggregates(slot, subcommitteeIndex, beaconBlockRoot, duties).catch((e: Error) => {
109
+ this.logger.error("Error on SyncCommitteeContribution", {slot, index: subcommitteeIndex}, e);
110
+ });
111
+ })
112
+ );
113
+ } catch (e) {
114
+ this.logger.error("Error on runSyncCommitteeTasks", {slot}, e as Error);
115
+ }
116
+ };
117
+
118
+ /**
119
+ * Performs the first step of the attesting process: downloading `SyncCommittee` objects,
120
+ * signing them and returning them to the validator.
121
+ *
122
+ * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/altair/validator.md#sync-committee-messages
123
+ *
124
+ * Only one `SyncCommittee` is downloaded from the BN. It is then signed by each
125
+ * validator and the list of individually-signed `SyncCommittee` objects is returned to the BN.
126
+ */
127
+ private async produceAndPublishSyncCommittees(slot: Slot, duties: SyncDutyAndProofs[]): Promise<Root> {
128
+ const logCtx = {slot};
129
+
130
+ // /eth/v1/beacon/blocks/:blockId/root -> at slot -1
131
+
132
+ // Produce one attestation data per slot and subcommitteeIndex
133
+ // Spec: the validator should prepare a SyncCommitteeMessage for the previous slot (slot - 1)
134
+ // as soon as they have determined the head block of slot - 1
135
+
136
+ const blockRoot: Uint8Array =
137
+ this.chainHeaderTracker.getCurrentChainHead(slot) ??
138
+ (await this.api.beacon.getBlockRoot({blockId: "head"})).value().root;
139
+
140
+ const signatures: altair.SyncCommitteeMessage[] = [];
141
+
142
+ await Promise.all(
143
+ duties.map(async ({duty}) => {
144
+ const logCtxValidator = {...logCtx, validatorIndex: duty.validatorIndex};
145
+ try {
146
+ signatures.push(
147
+ await this.validatorStore.signSyncCommitteeSignature(duty.pubkey, duty.validatorIndex, slot, blockRoot)
148
+ );
149
+ this.logger.debug("Signed SyncCommitteeMessage", logCtxValidator);
150
+ } catch (e) {
151
+ this.logger.error("Error signing SyncCommitteeMessage", logCtxValidator, e as Error);
152
+ }
153
+ })
154
+ );
155
+
156
+ // by default we want to submit SyncCommitteeSignature asap after we receive block
157
+ // provide a delay option just in case any client implementation validate the existence of block in
158
+ // SyncCommitteeSignature gossip validation.
159
+ const msToOneThirdSlot = this.clock.msToSlot(slot + 1 / 3);
160
+ const afterBlockDelayMs = 1000 * this.clock.secondsPerSlot * (this.opts?.scAfterBlockDelaySlotFraction ?? 0);
161
+ const toDelayMs = Math.min(msToOneThirdSlot, afterBlockDelayMs);
162
+ if (toDelayMs > 0) {
163
+ await sleep(toDelayMs);
164
+ }
165
+
166
+ this.metrics?.syncCommitteeStepCallPublishMessage.observe(this.clock.secFromSlot(slot + 1 / 3));
167
+
168
+ if (signatures.length > 0) {
169
+ try {
170
+ (await this.api.beacon.submitPoolSyncCommitteeSignatures({signatures})).assertOk();
171
+ this.logger.info("Published SyncCommitteeMessage", {...logCtx, count: signatures.length});
172
+ this.metrics?.publishedSyncCommitteeMessage.inc(signatures.length);
173
+ } catch (e) {
174
+ this.logger.error("Error publishing SyncCommitteeMessage", logCtx, e as Error);
175
+ }
176
+ }
177
+
178
+ return blockRoot;
179
+ }
180
+
181
+ /**
182
+ * Performs the second step of the attesting process: downloading an aggregated `SyncCommittee`,
183
+ * converting it into a `SignedAggregateAndProof` and returning it to the BN.
184
+ *
185
+ * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/altair/validator.md#sync-committee-contributions
186
+ *
187
+ * Only one aggregated `SyncCommittee` is downloaded from the BN. It is then signed
188
+ * by each validator and the list of individually-signed `SignedAggregateAndProof` objects is
189
+ * returned to the BN.
190
+ */
191
+ private async produceAndPublishAggregates(
192
+ slot: Slot,
193
+ subcommitteeIndex: CommitteeIndex,
194
+ beaconBlockRoot: Root,
195
+ duties: SubcommitteeDuty[]
196
+ ): Promise<void> {
197
+ const logCtx = {slot, index: subcommitteeIndex};
198
+
199
+ // No validator is aggregator, skip
200
+ if (duties.every(({selectionProof}) => selectionProof === null)) {
201
+ return;
202
+ }
203
+
204
+ this.logger.verbose("Producing SyncCommitteeContribution", logCtx);
205
+ const res = await this.api.validator.produceSyncCommitteeContribution({slot, subcommitteeIndex, beaconBlockRoot});
206
+
207
+ const signedContributions: altair.SignedContributionAndProof[] = [];
208
+
209
+ await Promise.all(
210
+ duties.map(async ({duty, selectionProof}) => {
211
+ const logCtxValidator = {...logCtx, validatorIndex: duty.validatorIndex};
212
+ try {
213
+ // Produce signed contributions only for validators that are subscribed aggregators.
214
+ if (selectionProof !== null) {
215
+ signedContributions.push(
216
+ await this.validatorStore.signContributionAndProof(duty, selectionProof, res.value())
217
+ );
218
+ this.logger.debug("Signed SyncCommitteeContribution", logCtxValidator);
219
+ }
220
+ } catch (e) {
221
+ this.logger.error("Error signing SyncCommitteeContribution", logCtxValidator, e as Error);
222
+ }
223
+ })
224
+ );
225
+
226
+ this.metrics?.syncCommitteeStepCallPublishAggregate.observe(this.clock.secFromSlot(slot + 2 / 3));
227
+
228
+ if (signedContributions.length > 0) {
229
+ try {
230
+ (
231
+ await this.api.validator.publishContributionAndProofs({contributionAndProofs: signedContributions})
232
+ ).assertOk();
233
+ this.logger.info("Published SyncCommitteeContribution", {...logCtx, count: signedContributions.length});
234
+ this.metrics?.publishedSyncCommitteeContribution.inc(signedContributions.length);
235
+ } catch (e) {
236
+ this.logger.error("Error publishing SyncCommitteeContribution", logCtx, e as Error);
237
+ }
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Performs additional sync committee contribution tasks required if validator is part of distributed cluster
243
+ *
244
+ * 1. Exchange partial for combined selection proofs
245
+ * 2. Determine validators that should produce sync committee contribution
246
+ * 3. Mutate duty objects to set selection proofs for aggregators
247
+ *
248
+ * See https://docs.google.com/document/d/1q9jOTPcYQa-3L8luRvQJ-M0eegtba4Nmon3dpO79TMk/mobilebasic
249
+ */
250
+ private async runDistributedAggregationSelectionTasks(
251
+ duties: SyncDutyAndProofs[],
252
+ slot: number,
253
+ signal: AbortSignal
254
+ ): Promise<void> {
255
+ const partialSelections: routes.validator.SyncCommitteeSelection[] = [];
256
+
257
+ for (const {duty, selectionProofs} of duties) {
258
+ const validatorSelections: routes.validator.SyncCommitteeSelection[] = selectionProofs.map(
259
+ ({subcommitteeIndex, partialSelectionProof}) => ({
260
+ validatorIndex: duty.validatorIndex,
261
+ slot,
262
+ subcommitteeIndex,
263
+ selectionProof: partialSelectionProof as BLSSignature,
264
+ })
265
+ );
266
+ partialSelections.push(...validatorSelections);
267
+ }
268
+
269
+ this.logger.debug("Submitting partial sync committee selection proofs", {slot, count: partialSelections.length});
270
+
271
+ const res = await Promise.race([
272
+ this.api.validator.submitSyncCommitteeSelections({selections: partialSelections}),
273
+ // Exit sync committee contributions flow if there is no response after 2/3 of slot.
274
+ // This is in contrast to attestations aggregations flow which is already exited at 1/3 of the slot
275
+ // because for sync committee is not required to resubscribe to subnets as beacon node will assume
276
+ // validator always aggregates. This allows us to wait until we have to produce sync committee contributions.
277
+ // Note that the sync committee contributions flow is not explicitly exited but rather will be skipped
278
+ // due to the fact that calculation of `is_sync_committee_aggregator` in SyncCommitteeDutiesService is not done
279
+ // and selectionProof is set to null, meaning no validator will be considered an aggregator.
280
+ sleep(this.clock.msToSlot(slot + 2 / 3), signal),
281
+ ]);
282
+
283
+ if (!res) {
284
+ throw new Error("Failed to receive combined selection proofs before 2/3 of slot");
285
+ }
286
+
287
+ const combinedSelections = res.value();
288
+ this.logger.debug("Received combined sync committee selection proofs", {slot, count: combinedSelections.length});
289
+
290
+ for (const dutyAndProofs of duties) {
291
+ const {validatorIndex, subnets} = dutyAndProofs.duty;
292
+
293
+ for (const subnet of subnets) {
294
+ const logCtxValidator = {slot, index: subnet, validatorIndex};
295
+
296
+ const combinedSelection = combinedSelections.find(
297
+ (s) => s.validatorIndex === validatorIndex && s.slot === slot && s.subcommitteeIndex === subnet
298
+ );
299
+
300
+ if (!combinedSelection) {
301
+ this.logger.warn("Did not receive combined sync committee selection proof", logCtxValidator);
302
+ continue;
303
+ }
304
+
305
+ const isAggregator = isSyncCommitteeAggregator(combinedSelection.selectionProof);
306
+
307
+ if (isAggregator) {
308
+ const selectionProofObject = dutyAndProofs.selectionProofs.find((p) => p.subcommitteeIndex === subnet);
309
+ if (selectionProofObject) {
310
+ // Update selection proof by mutating proof objects in duty object
311
+ selectionProofObject.selectionProof = combinedSelection.selectionProof;
312
+ }
313
+ }
314
+ }
315
+ }
316
+ }
317
+ }