@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/lib/services/block.d.ts +2 -1
- package/lib/services/block.d.ts.map +1 -1
- package/lib/services/block.js +4 -3
- package/lib/services/block.js.map +1 -1
- package/lib/services/blockDuties.d.ts +85 -7
- package/lib/services/blockDuties.d.ts.map +1 -1
- package/lib/services/blockDuties.js +186 -74
- package/lib/services/blockDuties.js.map +1 -1
- package/lib/services/proposerPreferences.d.ts +25 -0
- package/lib/services/proposerPreferences.d.ts.map +1 -0
- package/lib/services/proposerPreferences.js +101 -0
- package/lib/services/proposerPreferences.js.map +1 -0
- package/lib/services/validatorStore.d.ts +1 -0
- package/lib/services/validatorStore.d.ts.map +1 -1
- package/lib/services/validatorStore.js +25 -1
- package/lib/services/validatorStore.js.map +1 -1
- package/lib/util/externalSignerClient.d.ts +5 -1
- package/lib/util/externalSignerClient.d.ts.map +1 -1
- package/lib/util/externalSignerClient.js +4 -0
- package/lib/util/externalSignerClient.js.map +1 -1
- package/lib/util/params.js +1 -0
- package/lib/util/params.js.map +1 -1
- package/lib/validator.d.ts.map +1 -1
- package/lib/validator.js +5 -1
- package/lib/validator.js.map +1 -1
- package/package.json +12 -12
- package/src/services/block.ts +3 -9
- package/src/services/blockDuties.ts +212 -79
- package/src/services/proposerPreferences.ts +124 -0
- package/src/services/validatorStore.ts +37 -0
- package/src/util/externalSignerClient.ts +7 -1
- package/src/util/params.ts +1 -0
- package/src/validator.ts +27 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lodestar/validator",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
54
|
-
"@lodestar/config": "^1.
|
|
55
|
-
"@lodestar/db": "^1.
|
|
56
|
-
"@lodestar/params": "^1.
|
|
57
|
-
"@lodestar/state-transition": "^1.
|
|
58
|
-
"@lodestar/types": "^1.
|
|
59
|
-
"@lodestar/utils": "^1.
|
|
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.
|
|
64
|
-
"@lodestar/spec-test-util": "^1.
|
|
65
|
-
"@lodestar/test-utils": "^1.
|
|
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": "
|
|
69
|
+
"gitHead": "f34aa8a4d6de39ccb11977b25f1abce7ba6f7908"
|
|
70
70
|
}
|
package/src/services/block.ts
CHANGED
|
@@ -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 =
|
|
59
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
63
|
+
chainHeaderTracker: ChainHeaderTracker,
|
|
64
|
+
private readonly metrics: Metrics | null
|
|
41
65
|
) {
|
|
42
|
-
this.
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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 (
|
|
89
|
-
//
|
|
90
|
-
// Only fetch
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
151
|
+
this.logger.error("Error on runEveryEpochTask", {epoch}, e as Error);
|
|
99
152
|
} finally {
|
|
100
|
-
this.pruneOldDuties(
|
|
153
|
+
this.pruneOldDuties(Math.max(epoch, GENESIS_EPOCH));
|
|
101
154
|
}
|
|
102
155
|
};
|
|
103
156
|
|
|
104
157
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
-
*
|
|
124
|
-
*
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
*
|
|
169
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
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);
|