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