@lodestar/validator 1.43.0-rc.5 → 1.44.0-dev.1a8c38ee36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lodestar/validator",
3
- "version": "1.43.0-rc.5",
3
+ "version": "1.44.0-dev.1a8c38ee36",
4
4
  "description": "A Typescript implementation of the validator client",
5
5
  "author": "ChainSafe Systems",
6
6
  "license": "Apache-2.0",
@@ -50,21 +50,21 @@
50
50
  "dependencies": {
51
51
  "@chainsafe/blst": "^2.2.0",
52
52
  "@chainsafe/ssz": "^1.4.0",
53
- "@lodestar/api": "^1.43.0-rc.5",
54
- "@lodestar/config": "^1.43.0-rc.5",
55
- "@lodestar/db": "^1.43.0-rc.5",
56
- "@lodestar/params": "^1.43.0-rc.5",
57
- "@lodestar/state-transition": "^1.43.0-rc.5",
58
- "@lodestar/types": "^1.43.0-rc.5",
59
- "@lodestar/utils": "^1.43.0-rc.5",
53
+ "@lodestar/api": "^1.44.0-dev.1a8c38ee36",
54
+ "@lodestar/config": "^1.44.0-dev.1a8c38ee36",
55
+ "@lodestar/db": "^1.44.0-dev.1a8c38ee36",
56
+ "@lodestar/params": "^1.44.0-dev.1a8c38ee36",
57
+ "@lodestar/state-transition": "^1.44.0-dev.1a8c38ee36",
58
+ "@lodestar/types": "^1.44.0-dev.1a8c38ee36",
59
+ "@lodestar/utils": "^1.44.0-dev.1a8c38ee36",
60
60
  "strict-event-emitter-types": "^2.0.0"
61
61
  },
62
62
  "devDependencies": {
63
- "@lodestar/logger": "^1.43.0-rc.5",
64
- "@lodestar/spec-test-util": "^1.43.0-rc.5",
65
- "@lodestar/test-utils": "^1.43.0-rc.5",
63
+ "@lodestar/logger": "^1.44.0-dev.1a8c38ee36",
64
+ "@lodestar/spec-test-util": "^1.44.0-dev.1a8c38ee36",
65
+ "@lodestar/test-utils": "^1.44.0-dev.1a8c38ee36",
66
66
  "@vekexasia/bigint-buffer2": "^1.1.1",
67
67
  "rimraf": "^4.4.1"
68
68
  },
69
- "gitHead": "d7caddaad924496813904b3049464c02a99d7c46"
69
+ "gitHead": "f34aa8a4d6de39ccb11977b25f1abce7ba6f7908"
70
70
  }
@@ -52,18 +52,12 @@ export class BlockProposingService {
52
52
  private readonly api: ApiClient,
53
53
  private readonly clock: IClock,
54
54
  private readonly validatorStore: ValidatorStore,
55
+ dutiesService: BlockDutiesService,
55
56
  private readonly metrics: Metrics | null,
56
57
  private readonly opts: BlockProposalOpts
57
58
  ) {
58
- this.dutiesService = new BlockDutiesService(
59
- config,
60
- logger,
61
- api,
62
- clock,
63
- validatorStore,
64
- metrics,
65
- this.notifyBlockProductionFn
66
- );
59
+ this.dutiesService = dutiesService;
60
+ this.dutiesService.setNotifyBlockProductionFn(this.notifyBlockProductionFn);
67
61
  }
68
62
 
69
63
  removeDutiesForKey(pubkey: PubkeyHex): void {
@@ -1,18 +1,28 @@
1
1
  import {ApiClient, routes} from "@lodestar/api";
2
2
  import {ChainForkConfig} from "@lodestar/config";
3
+ import {isForkPostFulu} from "@lodestar/params";
3
4
  import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition";
4
5
  import {BLSPubkey, Epoch, RootHex, Slot} from "@lodestar/types";
5
6
  import {sleep, toPubkeyHex} from "@lodestar/utils";
6
7
  import {Metrics} from "../metrics.js";
7
8
  import {PubkeyHex} from "../types.js";
8
- import {IClock, LoggerVc, differenceHex} from "../util/index.js";
9
+ import {IClock, LoggerVc} from "../util/index.js";
10
+ import {ChainHeaderTracker, HeadEventData} from "./chainHeaderTracker.js";
9
11
  import {ValidatorStore} from "./validatorStore.js";
10
12
 
11
- /** This polls block duties 1s before the next epoch */
13
+ /**
14
+ * Pre-Fulu only: poll next-epoch proposer duties ~1s before the boundary. Post-Fulu the 1-epoch
15
+ * deterministic lookahead lets us pre-fetch via `runEveryEpoch`, so this fast path is unused.
16
+ *
17
+ * Historical context: starting Jul 2023 we poll 1s before the next epoch because
18
+ * `PrepareNextSlotScheduler` (BN-side) usually finishes the upcoming-epoch transition in ~3s,
19
+ * so the proposer-duties query at ~1s pre-boundary lands on a hot cache. See:
20
+ * - https://github.com/ChainSafe/lodestar/issues/5792
21
+ */
12
22
  // TODO: change to 8333 (5/6 of slot) to do it 2s before the next epoch
13
23
  // once we have some improvement on epoch transition time
14
24
  // see https://github.com/ChainSafe/lodestar/issues/5792#issuecomment-1647457442
15
- // TODO GLOAS: re-evaluate timing
25
+ // TODO GLOAS: re-evaluate timing — Gloas may want the offset *after* the boundary
16
26
  const BLOCK_DUTIES_LOOKAHEAD_BPS = 9167;
17
27
  /** Only retain `HISTORICAL_DUTIES_EPOCHS` duties prior to the current epoch */
18
28
  const HISTORICAL_DUTIES_EPOCHS = 2;
@@ -20,31 +30,42 @@ const HISTORICAL_DUTIES_EPOCHS = 2;
20
30
  const GENESIS_EPOCH = 0;
21
31
  export const GENESIS_SLOT = 0;
22
32
 
23
- type BlockDutyAtEpoch = {dependentRoot: RootHex; data: routes.validator.ProposerDuty[]};
33
+ export type BlockDutyAtEpoch = {dependentRoot: RootHex; data: routes.validator.ProposerDuty[]};
24
34
  type NotifyBlockProductionFn = (slot: Slot, proposers: BLSPubkey[]) => void;
25
35
 
26
36
  export class BlockDutiesService {
27
37
  /** Notify the block service if it should produce a block. */
28
- private readonly notifyBlockProductionFn: NotifyBlockProductionFn;
38
+ private notifyBlockProductionFn: NotifyBlockProductionFn = () => {};
29
39
  /** Maps an epoch to all *local* proposers in this epoch. Notably, this does not contain
30
40
  proposals for any validators which are not registered locally. */
31
41
  private readonly proposers = new Map<Epoch, BlockDutyAtEpoch>();
32
42
 
43
+ /**
44
+ * Tracks which proposer pubkeys we have already notified for the active slot so that
45
+ * a late-arriving cache update (SSE-driven refetch, slow initial poll) only fires
46
+ * `notifyBlockProductionFn` for *newly discovered* proposers, never duplicates.
47
+ */
48
+ private notifiedSlot: Slot = -1;
49
+ private readonly notifiedProposers = new Set<PubkeyHex>();
50
+ /**
51
+ * True once `notifyProposersForSlot` has been invoked for `notifiedSlot`, regardless of
52
+ * whether anything was notified. Any subsequent invocation that finds *new* proposers is
53
+ * therefore a late detection — the signal tracked by `newProposalDutiesDetected`.
54
+ */
55
+ private notifiedSlotInitialPass = false;
56
+
33
57
  constructor(
34
58
  private readonly config: ChainForkConfig,
35
59
  private readonly logger: LoggerVc,
36
60
  private readonly api: ApiClient,
37
61
  private readonly clock: IClock,
38
62
  private readonly validatorStore: ValidatorStore,
39
- private readonly metrics: Metrics | null,
40
- notifyBlockProductionFn: NotifyBlockProductionFn
63
+ chainHeaderTracker: ChainHeaderTracker,
64
+ private readonly metrics: Metrics | null
41
65
  ) {
42
- this.notifyBlockProductionFn = notifyBlockProductionFn;
43
-
44
- // TODO: Instead of polling every CLOCK_SLOT, poll every CLOCK_EPOCH and track re-org events
45
- // only then re-fetch the block duties. Make sure most clients (including Lodestar)
46
- // properly emit the re-org event
47
- clock.runEverySlot(this.runBlockDutiesTask);
66
+ clock.runEveryEpoch(this.runEveryEpochTask);
67
+ clock.runEverySlot(this.runEverySlotTask);
68
+ chainHeaderTracker.runOnNewHead(this.onNewHead);
48
69
 
49
70
  if (metrics) {
50
71
  metrics.proposerDutiesEpochCount.addCollect(() => {
@@ -53,6 +74,14 @@ export class BlockDutiesService {
53
74
  }
54
75
  }
55
76
 
77
+ /**
78
+ * Late-bind the production callback. Allows the duties service to be constructed
79
+ * before the consumer that handles proposal production.
80
+ */
81
+ setNotifyBlockProductionFn(notifyBlockProductionFn: NotifyBlockProductionFn): void {
82
+ this.notifyBlockProductionFn = notifyBlockProductionFn;
83
+ }
84
+
56
85
  /**
57
86
  * Returns the pubkeys of the validators which are assigned to propose in the given slot.
58
87
  *
@@ -75,6 +104,16 @@ export class BlockDutiesService {
75
104
  return Array.from(publicKeys.values());
76
105
  }
77
106
 
107
+ /**
108
+ * Returns the cached `{dependentRoot, data}` entry for `epoch`, or `undefined` if duties
109
+ * for that epoch are not yet known. Consumers can detect a proposer-shuffling change
110
+ * (e.g. after a reorg) by observing a different `dependentRoot` than the one they last
111
+ * read for the same epoch.
112
+ */
113
+ getProposersAtEpoch(epoch: Epoch): BlockDutyAtEpoch | undefined {
114
+ return this.proposers.get(epoch);
115
+ }
116
+
78
117
  removeDutiesForKey(pubkey: PubkeyHex): void {
79
118
  for (const blockDutyAtEpoch of this.proposers.values()) {
80
119
  blockDutyAtEpoch.data = blockDutyAtEpoch.data.filter((proposer) => {
@@ -83,108 +122,191 @@ export class BlockDutiesService {
83
122
  }
84
123
  }
85
124
 
86
- private runBlockDutiesTask = async (slot: Slot, signal: AbortSignal): Promise<void> => {
125
+ /**
126
+ * Baseline per-epoch fetch. Fires at epoch boundaries (and once at startup). Post-Fulu the
127
+ * deterministic 1-epoch lookahead lets us also pre-fetch `epoch + 1`; pre-Fulu the next
128
+ * epoch's dep_root only stabilizes at the boundary and is handled by `runEverySlotTask`.
129
+ *
130
+ * Mid-epoch refreshes (e.g. reorgs) are driven by `onNewHead` instead of polling every slot.
131
+ */
132
+ private runEveryEpochTask = async (epoch: Epoch): Promise<void> => {
87
133
  try {
88
- if (slot < 0) {
89
- // Before genesis, fetch the genesis duties but don't notify block production
90
- // Only fetch duties once since there is not possible to re-org. TODO: Review
134
+ if (epoch < GENESIS_EPOCH) {
135
+ // Pre-genesis: prime the genesis-epoch duties exactly once so the slot-0 proposer
136
+ // doesn't have to wait on a cold cache. Only fetch once since a pre-genesis re-org
137
+ // is not possible. TODO: Review.
91
138
  if (!this.proposers.has(GENESIS_EPOCH)) {
92
139
  await this.pollBeaconProposers(GENESIS_EPOCH);
93
140
  }
94
- } else {
95
- await this.pollBeaconProposersAndNotify(slot, signal);
141
+ return;
142
+ }
143
+
144
+ await this.pollBeaconProposers(epoch);
145
+
146
+ const nextEpoch = epoch + 1;
147
+ if (isForkPostFulu(this.config.getForkName(computeStartSlotAtEpoch(nextEpoch)))) {
148
+ await this.pollBeaconProposers(nextEpoch);
96
149
  }
97
150
  } catch (e) {
98
- this.logger.error("Error on pollBeaconProposers", {}, e as Error);
151
+ this.logger.error("Error on runEveryEpochTask", {epoch}, e as Error);
99
152
  } finally {
100
- this.pruneOldDuties(computeEpochAtSlot(slot));
153
+ this.pruneOldDuties(Math.max(epoch, GENESIS_EPOCH));
101
154
  }
102
155
  };
103
156
 
104
157
  /**
105
- * Download the proposer duties for the current epoch and store them in `this.proposers`.
106
- * If there are any proposer for this slot, send out a notification to the block proposers.
107
- *
108
- * ## Note
109
- *
110
- * This function will potentially send *two* notifications to the `BlockService`; it will send a
111
- * notification initially, then it will download the latest duties and send a *second* notification
112
- * if those duties have changed. This behaviour simultaneously achieves the following:
158
+ * Slot-tick handler. Notifies block production for cached proposers in this slot, and on
159
+ * the last slot of a pre-Fulu epoch schedules the boundary fetch for `nextEpoch` duties.
160
+ * Reorg detection is handled by `onNewHead`, so this task does not re-poll on every slot.
161
+ */
162
+ private runEverySlotTask = async (slot: Slot, signal: AbortSignal): Promise<void> => {
163
+ try {
164
+ if (slot < GENESIS_SLOT) {
165
+ return;
166
+ }
167
+
168
+ this.notifyProposersForSlot(slot);
169
+
170
+ const nextEpoch = computeEpochAtSlot(slot) + 1;
171
+ const isLastSlotOfEpoch = computeStartSlotAtEpoch(nextEpoch) === slot + 1;
172
+ if (isLastSlotOfEpoch && !isForkPostFulu(this.config.getForkName(slot + 1))) {
173
+ // Pre-Fulu: 0-epoch proposer lookahead, so the next-epoch dep_root only becomes stable
174
+ // as the last block of the current epoch lands. Sleep until ~1s before the boundary
175
+ // then fetch — same timing as before this refactor.
176
+ this.pollBeaconProposersBeforeBoundary(slot, nextEpoch, signal).catch((e) => {
177
+ this.logger.error("Error on pollBeaconProposersBeforeBoundary", {nextEpoch}, e);
178
+ });
179
+ }
180
+ } catch (e) {
181
+ this.logger.error("Error on runEverySlotTask", {slot}, e as Error);
182
+ }
183
+ };
184
+
185
+ /**
186
+ * SSE head-event handler. The beacon-API `head` event carries attester-duty dep_roots,
187
+ * which coincide with the proposer dep_roots at a fork-dependent offset:
113
188
  *
114
- * 1. Block production can happen immediately and does not have to wait for the proposer duties to
115
- * download.
116
- * 2. We won't miss a block if the duties for the current slot happen to change with this poll.
189
+ * Pre-Fulu (proposer dep_root(E) = block@startSlot(E) - 1):
190
+ * currentDutyDependentRoot ≡ proposer_dep_root(currentEpoch)
191
+ * (next-epoch proposer dep_root is not exposed; pre-Fulu falls back to the
192
+ * `runEverySlotTask` boundary poll.)
117
193
  *
118
- * This sounds great, but is it safe? Firstly, the additional notification will only contain block
119
- * producers that were not included in the first notification. This should be safety enough.
120
- * However, we also have the slashing protection as a second line of defense. These two factors
121
- * provide an acceptable level of safety.
194
+ * Post-Fulu (proposer dep_root(E) = block@startSlot(E - 1) - 1, EIP-7917):
195
+ * previousDutyDependentRoot proposer_dep_root(currentEpoch)
196
+ * currentDutyDependentRoot ≡ proposer_dep_root(nextEpoch)
122
197
  *
123
- * It's important to note that since there is a 0-epoch look-ahead (i.e., no look-ahead) for block
124
- * proposers then it's very likely that a proposal for the first slot of the epoch will need go
125
- * through the slow path every time. I.e., the proposal will only happen after we've been able to
126
- * download and process the duties from the BN. This means it is very important to ensure this
127
- * function is as fast as possible.
128
- * - Starting from Jul 2023, we poll proposers 1s before the next epoch thanks to PrepareNextSlotScheduler
129
- * usually finishes in 3s.
198
+ * On a dep_root mismatch (reorg, or initial sync delivering a fresher head) we refetch
199
+ * just the affected epoch, mirroring `AttestationDutiesService.onNewHead`.
130
200
  */
131
- private async pollBeaconProposersAndNotify(currentSlot: Slot, signal: AbortSignal): Promise<void> {
132
- const nextEpoch = computeEpochAtSlot(currentSlot) + 1;
133
- const isLastSlotEpoch = computeStartSlotAtEpoch(nextEpoch) === currentSlot + 1;
134
- if (isLastSlotEpoch) {
135
- // no need to await for other steps, just poll proposers for next epoch
136
- this.pollBeaconProposersNextEpoch(currentSlot, nextEpoch, signal).catch((e) => {
137
- this.logger.error("Error on pollBeaconProposersNextEpoch", {}, e);
138
- });
139
- }
201
+ private onNewHead = async ({
202
+ slot,
203
+ previousDutyDependentRoot,
204
+ currentDutyDependentRoot,
205
+ }: HeadEventData): Promise<void> => {
206
+ const currentEpoch = computeEpochAtSlot(slot);
207
+ const isPostFulu = isForkPostFulu(this.config.getForkName(slot));
140
208
 
141
- // Notify the block proposal service for any proposals that we have in our cache.
142
- const initialBlockProposers = this.getblockProposersAtSlot(currentSlot);
143
- if (initialBlockProposers.length > 0) {
144
- this.notifyBlockProductionFn(currentSlot, initialBlockProposers);
209
+ if (isPostFulu) {
210
+ await this.refetchIfDepRootChanged(currentEpoch, previousDutyDependentRoot);
211
+ await this.refetchIfDepRootChanged(currentEpoch + 1, currentDutyDependentRoot);
212
+ } else {
213
+ await this.refetchIfDepRootChanged(currentEpoch, currentDutyDependentRoot);
145
214
  }
215
+ };
146
216
 
147
- // Poll proposers again for the same slot
148
- await this.pollBeaconProposers(computeEpochAtSlot(currentSlot));
149
-
150
- // Compute the block proposers for this slot again, now that we've received an update from the BN.
151
- //
152
- // Then, compute the difference between these two sets to obtain a set of block proposers
153
- // which were not included in the initial notification to the `BlockService`.
154
- const newBlockProducers = this.getblockProposersAtSlot(currentSlot);
155
- const additionalBlockProducers = differenceHex(initialBlockProposers, newBlockProducers);
156
-
157
- // If there are any new proposers for this slot, send a notification so they produce a block.
158
- //
159
- // See the function-level documentation for more reasoning about this behaviour.
160
- if (additionalBlockProducers.length > 0) {
161
- this.notifyBlockProductionFn(currentSlot, additionalBlockProducers);
162
- this.logger.debug("Detected new block proposer", {currentSlot});
163
- this.metrics?.newProposalDutiesDetected.inc();
217
+ private async refetchIfDepRootChanged(epoch: Epoch, expectedDepRoot: RootHex): Promise<void> {
218
+ const cached = this.proposers.get(epoch);
219
+ if (!cached || cached.dependentRoot === expectedDepRoot) {
220
+ return;
164
221
  }
222
+
223
+ this.logger.debug("Proposer duties dep_root changed, refetching", {
224
+ epoch,
225
+ priorDependentRoot: cached.dependentRoot,
226
+ newDependentRoot: expectedDepRoot,
227
+ });
228
+ await this.pollBeaconProposers(epoch);
165
229
  }
166
230
 
167
231
  /**
168
- * This is to avoid some delay on the first slot of the epoch when validators have proposal duties.
169
- * See https://github.com/ChainSafe/lodestar/issues/5792
232
+ * Pre-Fulu boundary fetch. Because pre-Fulu proposer shuffling has 0-epoch look-ahead, a
233
+ * proposal for the first slot of the new epoch otherwise goes through the slow path every
234
+ * time: the proposal can only happen *after* we download and process the new duties from
235
+ * the BN. Polling ~1s before the boundary, while `PrepareNextSlotScheduler` is finishing
236
+ * the upcoming-epoch transition, lets us land on a hot BN cache and avoid the miss.
237
+ *
238
+ * See https://github.com/ChainSafe/lodestar/issues/5792.
170
239
  */
171
- private async pollBeaconProposersNextEpoch(currentSlot: Slot, nextEpoch: Epoch, signal: AbortSignal): Promise<void> {
240
+ private async pollBeaconProposersBeforeBoundary(
241
+ currentSlot: Slot,
242
+ nextEpoch: Epoch,
243
+ signal: AbortSignal
244
+ ): Promise<void> {
172
245
  const nextSlot = currentSlot + 1;
173
246
  const lookAheadMs =
174
247
  this.config.SLOT_DURATION_MS - this.config.getSlotComponentDurationMs(BLOCK_DUTIES_LOOKAHEAD_BPS);
175
248
  await sleep(this.clock.msToSlot(nextSlot) - lookAheadMs, signal);
176
- this.logger.debug("Polling proposers for next epoch", {nextEpoch, nextSlot});
177
- // Poll proposers for the next epoch
249
+ this.logger.debug("Polling proposers for the next epoch", {nextEpoch, currentSlot});
178
250
  await this.pollBeaconProposers(nextEpoch);
179
251
  }
180
252
 
253
+ /**
254
+ * Notify block production for *newly discovered* proposers in this slot. Notifications are
255
+ * deduplicated per-slot so that a late SSE refetch can extend the proposer set without
256
+ * triggering a duplicate `createAndPublishBlock` for already-notified validators.
257
+ *
258
+ * ## Multi-notification safety
259
+ *
260
+ * Within a single slot the cache can be updated from several sources (cold-cache backfill at
261
+ * startup, SSE-driven reorg refetch). Each update may fire this function again. The contract
262
+ * we keep is: each pubkey is notified *at most once per slot*. The additional notifications
263
+ * only carry proposers that were not part of an earlier notification.
264
+ *
265
+ * Is this safe? Firstly, the dedup above guarantees we never ask the same validator to
266
+ * propose twice for the same slot. Secondly, slashing protection in `ValidatorStore` acts as
267
+ * a second line of defense should the dedup ever fail. Together they provide an acceptable
268
+ * level of safety for the "notify-from-cache, refine-after-refetch" pattern.
269
+ */
270
+ private notifyProposersForSlot(slot: Slot): void {
271
+ if (slot !== this.notifiedSlot) {
272
+ this.notifiedSlot = slot;
273
+ this.notifiedSlotInitialPass = false;
274
+ this.notifiedProposers.clear();
275
+ }
276
+
277
+ const isLateDetection = this.notifiedSlotInitialPass;
278
+ this.notifiedSlotInitialPass = true;
279
+
280
+ const newProposers: BLSPubkey[] = [];
281
+ for (const pubkey of this.getblockProposersAtSlot(slot)) {
282
+ const pubkeyHex = toPubkeyHex(pubkey);
283
+ if (!this.notifiedProposers.has(pubkeyHex)) {
284
+ this.notifiedProposers.add(pubkeyHex);
285
+ newProposers.push(pubkey);
286
+ }
287
+ }
288
+
289
+ if (newProposers.length === 0) {
290
+ return;
291
+ }
292
+
293
+ if (isLateDetection) {
294
+ this.metrics?.newProposalDutiesDetected.inc();
295
+ this.logger.debug("Detected new block proposer", {slot});
296
+ }
297
+ this.notifyBlockProductionFn(slot, newProposers);
298
+ }
299
+
181
300
  private async pollBeaconProposers(epoch: Epoch): Promise<void> {
182
301
  // Only download duties and push out additional block production events if we have some validators.
183
302
  if (!this.validatorStore.hasSomeValidators()) {
184
303
  return;
185
304
  }
186
305
 
187
- const res = await this.api.validator.getProposerDuties({epoch});
306
+ // Post-Fulu the proposer dependent root changed (deterministic proposer lookahead)
307
+ const res = isForkPostFulu(this.config.getForkName(computeStartSlotAtEpoch(epoch)))
308
+ ? await this.api.validator.getProposerDutiesV2({epoch})
309
+ : await this.api.validator.getProposerDuties({epoch});
188
310
  const proposerDuties = res.value();
189
311
  const {dependentRoot} = res.meta();
190
312
  const relevantDuties = proposerDuties.filter((duty) => {
@@ -195,6 +317,11 @@ export class BlockDutiesService {
195
317
  this.logger.debug("Downloaded proposer duties", {epoch, dependentRoot, count: relevantDuties.length});
196
318
 
197
319
  const prior = this.proposers.get(epoch);
320
+ // Concurrent polls for the same epoch (e.g. `onNewHead` and `runEveryEpochTask` racing)
321
+ // both write here last-write-wins. The pre-refactor per-slot poll healed any stale write
322
+ // on the next slot; in the event-driven model staleness can persist until the next
323
+ // dep_root change. In practice the same BN serves both calls so they return identical
324
+ // payloads — accept the rare race rather than serialising fetches.
198
325
  this.proposers.set(epoch, {dependentRoot, data: relevantDuties});
199
326
 
200
327
  if (prior && prior.dependentRoot !== dependentRoot) {
@@ -204,6 +331,12 @@ export class BlockDutiesService {
204
331
  dependentRoot,
205
332
  });
206
333
  }
334
+
335
+ // If this fetch revealed proposer(s) for the active slot that the last `runEverySlotTask`
336
+ // missed (cold cache at startup, or duties shifted by a reorg), notify now.
337
+ if (this.notifiedSlot >= GENESIS_SLOT && computeEpochAtSlot(this.notifiedSlot) === epoch) {
338
+ this.notifyProposersForSlot(this.notifiedSlot);
339
+ }
207
340
  }
208
341
 
209
342
  /** Run once per epoch to prune `this.proposers` map */
@@ -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
+ }
@@ -9,6 +9,7 @@ import {
9
9
  DOMAIN_BEACON_BUILDER,
10
10
  DOMAIN_BEACON_PROPOSER,
11
11
  DOMAIN_CONTRIBUTION_AND_PROOF,
12
+ DOMAIN_PROPOSER_PREFERENCES,
12
13
  DOMAIN_PTC_ATTESTER,
13
14
  DOMAIN_RANDAO,
14
15
  DOMAIN_SELECTION_PROOF,
@@ -704,6 +705,42 @@ export class ValidatorStore {
704
705
  };
705
706
  }
706
707
 
708
+ async signProposerPreferences(
709
+ duty: routes.validator.ProposerDuty,
710
+ dependentRoot: Uint8Array,
711
+ feeRecipient: ExecutionAddress,
712
+ gasLimit: number,
713
+ currentSlot: Slot
714
+ ): Promise<gloas.SignedProposerPreferences> {
715
+ if (duty.slot <= currentSlot) {
716
+ throw Error(`Not signing proposer preferences for past slot ${duty.slot} (current ${currentSlot})`);
717
+ }
718
+
719
+ this.assertDoppelgangerSafe(duty.pubkey);
720
+
721
+ const message: gloas.ProposerPreferences = {
722
+ dependentRoot,
723
+ proposalSlot: duty.slot,
724
+ validatorIndex: duty.validatorIndex,
725
+ feeRecipient: fromHex(feeRecipient),
726
+ targetGasLimit: gasLimit,
727
+ };
728
+
729
+ const signingSlot = duty.slot;
730
+ const domain = this.config.getDomain(signingSlot, DOMAIN_PROPOSER_PREFERENCES);
731
+ const signingRoot = computeSigningRoot(ssz.gloas.ProposerPreferences, message, domain);
732
+
733
+ const signableMessage: SignableMessage = {
734
+ type: SignableMessageType.PROPOSER_PREFERENCES,
735
+ data: message,
736
+ };
737
+
738
+ return {
739
+ message,
740
+ signature: await this.getSignature(duty.pubkey, signingRoot, signingSlot, signableMessage),
741
+ };
742
+ }
743
+
707
744
  async signAttestationSelectionProof(pubkey: BLSPubkeyMaybeHex, slot: Slot): Promise<BLSSignature> {
708
745
  const signingSlot = slot;
709
746
  const domain = this.config.getDomain(slot, DOMAIN_SELECTION_PROOF);