Skip to content

Commit 3a6702e

Browse files
authored
feat: restart aware doppelganger protection (#6012)
* Skip doppelganger detection if validator attested in previous epoch * Async initialization of ValidatorStore and Validator class * Add attested in prev epoch doppelganger unit test * Fix typos in doppelganger * Update doppelganger service register validator logs * Use jsdoc to document doppelganger statuses * Use prettyBytes to print out pubkeys * Add comment about doppelganger and signer registration order * Print out validator pubkey first * Update validator client jsdoc * Revise doppelganger registration logs * Truncate and format bytes as 0x123456789abc * Add reference to github issue * Revise logs final * Update tests to ensure correct epoch is provided
1 parent a05e1b3 commit 3a6702e

16 files changed

+332
-125
lines changed

packages/cli/src/cmds/validator/keymanager/impl.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export class KeymanagerApi implements Api {
149149

150150
decryptKeystores.queue(
151151
{keystoreStr, password},
152-
(secretKeyBytes: Uint8Array) => {
152+
async (secretKeyBytes: Uint8Array) => {
153153
const secretKey = bls.SecretKey.fromBytes(secretKeyBytes);
154154

155155
// Persist the key to disk for restarts, before adding to in-memory store
@@ -165,7 +165,7 @@ export class KeymanagerApi implements Api {
165165
});
166166

167167
// Add to in-memory store to start validating immediately
168-
this.validator.validatorStore.addSigner({type: SignerType.Local, secretKey});
168+
await this.validator.validatorStore.addSigner({type: SignerType.Local, secretKey});
169169

170170
statuses[i] = {status: ImportStatus.imported};
171171
},
@@ -292,7 +292,7 @@ export class KeymanagerApi implements Api {
292292
async importRemoteKeys(
293293
remoteSigners: Pick<SignerDefinition, "pubkey" | "url">[]
294294
): ReturnType<Api["importRemoteKeys"]> {
295-
const results = remoteSigners.map(({pubkey, url}): ResponseStatus<ImportRemoteKeyStatus> => {
295+
const importPromises = remoteSigners.map(async ({pubkey, url}): Promise<ResponseStatus<ImportRemoteKeyStatus>> => {
296296
try {
297297
if (!isValidatePubkeyHex(pubkey)) {
298298
throw Error(`Invalid pubkey ${pubkey}`);
@@ -308,7 +308,7 @@ export class KeymanagerApi implements Api {
308308

309309
// Else try to add it
310310

311-
this.validator.validatorStore.addSigner({type: SignerType.Remote, pubkey, url});
311+
await this.validator.validatorStore.addSigner({type: SignerType.Remote, pubkey, url});
312312

313313
this.persistedKeysBackend.writeRemoteKey({
314314
pubkey,
@@ -325,7 +325,7 @@ export class KeymanagerApi implements Api {
325325
});
326326

327327
return {
328-
data: results,
328+
data: await Promise.all(importPromises),
329329
};
330330
}
331331

packages/utils/src/format.ts

+10
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,13 @@ export function prettyBytesShort(root: Uint8Array | string): string {
1717
const str = typeof root === "string" ? root : toHexString(root);
1818
return `${str.slice(0, 6)}…`;
1919
}
20+
21+
/**
22+
* Truncate and format bytes as `0x123456789abc`
23+
* 6 bytes is sufficient to avoid collisions and it allows to easily look up
24+
* values on explorers like beaconcha.in while improving readability of logs
25+
*/
26+
export function truncBytes(root: Uint8Array | string): string {
27+
const str = typeof root === "string" ? root : toHexString(root);
28+
return str.slice(0, 14);
29+
}

packages/validator/src/services/doppelgangerService.ts

+43-14
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import {fromHexString} from "@chainsafe/ssz";
12
import {Epoch, ValidatorIndex} from "@lodestar/types";
23
import {Api, ApiError, routes} from "@lodestar/api";
3-
import {Logger, sleep} from "@lodestar/utils";
4+
import {Logger, sleep, truncBytes} from "@lodestar/utils";
45
import {computeStartSlotAtEpoch} from "@lodestar/state-transition";
6+
import {ISlashingProtection} from "../slashingProtection/index.js";
57
import {ProcessShutdownCallback, PubkeyHex} from "../types.js";
68
import {IClock} from "../util/index.js";
79
import {Metrics} from "../metrics.js";
@@ -10,7 +12,8 @@ import {IndicesService} from "./indices.js";
1012
// The number of epochs that must be checked before we assume that there are
1113
// no other duplicate validators on the network
1214
const DEFAULT_REMAINING_DETECTION_EPOCHS = 1;
13-
const REMAINING_EPOCHS_IF_DOPPLEGANGER = Infinity;
15+
const REMAINING_EPOCHS_IF_DOPPELGANGER = Infinity;
16+
const REMAINING_EPOCHS_IF_SKIPPED = 0;
1417

1518
/** Liveness responses for a given epoch */
1619
type EpochLivenessData = {
@@ -24,13 +27,13 @@ export type DoppelgangerState = {
2427
};
2528

2629
export enum DoppelgangerStatus {
27-
// This pubkey is known to the doppelganger service and has been verified safe
30+
/** This pubkey is known to the doppelganger service and has been verified safe */
2831
VerifiedSafe = "VerifiedSafe",
29-
// This pubkey is known to the doppelganger service but has not been verified safe
32+
/** This pubkey is known to the doppelganger service but has not been verified safe */
3033
Unverified = "Unverified",
31-
// This pubkey is unknown to the doppelganger service
34+
/** This pubkey is unknown to the doppelganger service */
3235
Unknown = "Unknown",
33-
// This pubkey has been detected to be active on the network
36+
/** This pubkey has been detected to be active on the network */
3437
DoppelgangerDetected = "DoppelgangerDetected",
3538
}
3639

@@ -42,6 +45,7 @@ export class DoppelgangerService {
4245
private readonly clock: IClock,
4346
private readonly api: Api,
4447
private readonly indicesService: IndicesService,
48+
private readonly slashingProtection: ISlashingProtection,
4549
private readonly processShutdownCallback: ProcessShutdownCallback,
4650
private readonly metrics: Metrics | null
4751
) {
@@ -54,16 +58,41 @@ export class DoppelgangerService {
5458
this.logger.info("Doppelganger protection enabled", {detectionEpochs: DEFAULT_REMAINING_DETECTION_EPOCHS});
5559
}
5660

57-
registerValidator(pubkeyHex: PubkeyHex): void {
61+
async registerValidator(pubkeyHex: PubkeyHex): Promise<void> {
5862
const {currentEpoch} = this.clock;
5963
// Disable doppelganger protection when the validator was initialized before genesis.
6064
// There's no activity before genesis, so doppelganger is pointless.
61-
const remainingEpochs = currentEpoch <= 0 ? 0 : DEFAULT_REMAINING_DETECTION_EPOCHS;
65+
let remainingEpochs = currentEpoch <= 0 ? REMAINING_EPOCHS_IF_SKIPPED : DEFAULT_REMAINING_DETECTION_EPOCHS;
6266
const nextEpochToCheck = currentEpoch + 1;
6367

6468
// Log here to alert that validation won't be active until remainingEpochs == 0
6569
if (remainingEpochs > 0) {
66-
this.logger.info("Registered validator for doppelganger", {remainingEpochs, nextEpochToCheck, pubkeyHex});
70+
const previousEpoch = currentEpoch - 1;
71+
const attestedInPreviousEpoch = await this.slashingProtection.hasAttestedInEpoch(
72+
fromHexString(pubkeyHex),
73+
previousEpoch
74+
);
75+
76+
if (attestedInPreviousEpoch) {
77+
// It is safe to skip doppelganger detection
78+
// https://github.com/ChainSafe/lodestar/issues/5856
79+
remainingEpochs = REMAINING_EPOCHS_IF_SKIPPED;
80+
this.logger.info("Doppelganger detection skipped for validator because restart was detected", {
81+
pubkey: truncBytes(pubkeyHex),
82+
previousEpoch,
83+
});
84+
} else {
85+
this.logger.info("Registered validator for doppelganger detection", {
86+
pubkey: truncBytes(pubkeyHex),
87+
remainingEpochs,
88+
nextEpochToCheck,
89+
});
90+
}
91+
} else {
92+
this.logger.info("Doppelganger detection skipped for validator initialized before genesis", {
93+
pubkey: truncBytes(pubkeyHex),
94+
currentEpoch,
95+
});
6796
}
6897

6998
this.doppelgangerStateByPubkey.set(pubkeyHex, {
@@ -180,7 +209,7 @@ export class DoppelgangerService {
180209
}
181210

182211
if (state.nextEpochToCheck <= epoch) {
183-
// Doppleganger detected
212+
// Doppelganger detected
184213
violators.push(response.index);
185214
}
186215
}
@@ -189,7 +218,7 @@ export class DoppelgangerService {
189218
if (violators.length > 0) {
190219
// If a single doppelganger is detected, enable doppelganger checks on all validators forever
191220
for (const state of this.doppelgangerStateByPubkey.values()) {
192-
state.remainingEpochs = Infinity;
221+
state.remainingEpochs = REMAINING_EPOCHS_IF_DOPPELGANGER;
193222
}
194223

195224
this.logger.error(
@@ -225,9 +254,9 @@ export class DoppelgangerService {
225254

226255
const {remainingEpochs, nextEpochToCheck} = state;
227256
if (remainingEpochs <= 0) {
228-
this.logger.info("Doppelganger detection complete", {index: response.index});
257+
this.logger.info("Doppelganger detection complete", {index: response.index, epoch: currentEpoch});
229258
} else {
230-
this.logger.info("Found no doppelganger", {remainingEpochs, nextEpochToCheck, index: response.index});
259+
this.logger.info("Found no doppelganger", {index: response.index, remainingEpochs, nextEpochToCheck});
231260
}
232261
}
233262
}
@@ -253,7 +282,7 @@ function getStatus(state: DoppelgangerState | undefined): DoppelgangerStatus {
253282
return DoppelgangerStatus.Unknown;
254283
} else if (state.remainingEpochs <= 0) {
255284
return DoppelgangerStatus.VerifiedSafe;
256-
} else if (state.remainingEpochs === REMAINING_EPOCHS_IF_DOPPLEGANGER) {
285+
} else if (state.remainingEpochs === REMAINING_EPOCHS_IF_DOPPELGANGER) {
257286
return DoppelgangerStatus.DoppelgangerDetected;
258287
} else {
259288
return DoppelgangerStatus.Unverified;

packages/validator/src/services/validatorStore.ts

+41-17
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ export type ValidatorProposerConfig = {
9797
defaultConfig: ProposerConfig;
9898
};
9999

100+
export type ValidatorStoreModules = {
101+
config: BeaconConfig;
102+
slashingProtection: ISlashingProtection;
103+
indicesService: IndicesService;
104+
doppelgangerService: DoppelgangerService | null;
105+
metrics: Metrics | null;
106+
};
107+
100108
/**
101109
* This cache stores SignedValidatorRegistrationV1 data for a validator so that
102110
* we do not create and send new registration objects to avoid DOSing the builder
@@ -130,21 +138,25 @@ export const defaultOptions = {
130138
* Service that sets up and handles validator attester duties.
131139
*/
132140
export class ValidatorStore {
141+
private readonly config: BeaconConfig;
142+
private readonly slashingProtection: ISlashingProtection;
143+
private readonly indicesService: IndicesService;
144+
private readonly doppelgangerService: DoppelgangerService | null;
145+
private readonly metrics: Metrics | null;
146+
133147
private readonly validators = new Map<PubkeyHex, ValidatorData>();
134148
/** Initially true because there are no validators */
135149
private pubkeysToDiscover: PubkeyHex[] = [];
136150
private readonly defaultProposerConfig: DefaultProposerConfig;
137151

138-
constructor(
139-
private readonly config: BeaconConfig,
140-
private readonly slashingProtection: ISlashingProtection,
141-
private readonly indicesService: IndicesService,
142-
private readonly doppelgangerService: DoppelgangerService | null,
143-
private readonly metrics: Metrics | null,
144-
initialSigners: Signer[],
145-
valProposerConfig: ValidatorProposerConfig = {defaultConfig: {}, proposerConfig: {}},
146-
private readonly genesisValidatorRoot: Root
147-
) {
152+
constructor(modules: ValidatorStoreModules, valProposerConfig: ValidatorProposerConfig) {
153+
const {config, slashingProtection, indicesService, doppelgangerService, metrics} = modules;
154+
this.config = config;
155+
this.slashingProtection = slashingProtection;
156+
this.indicesService = indicesService;
157+
this.doppelgangerService = doppelgangerService;
158+
this.metrics = metrics;
159+
148160
const defaultConfig = valProposerConfig.defaultConfig;
149161
this.defaultProposerConfig = {
150162
graffiti: defaultConfig.graffiti ?? "",
@@ -157,15 +169,26 @@ export class ValidatorStore {
157169
},
158170
};
159171

160-
for (const signer of initialSigners) {
161-
this.addSigner(signer, valProposerConfig);
162-
}
163-
164172
if (metrics) {
165173
metrics.signers.addCollect(() => metrics.signers.set(this.validators.size));
166174
}
167175
}
168176

177+
/**
178+
* Create a validator store with initial signers
179+
*/
180+
static async init(
181+
modules: ValidatorStoreModules,
182+
initialSigners: Signer[],
183+
valProposerConfig: ValidatorProposerConfig = {defaultConfig: {}, proposerConfig: {}}
184+
): Promise<ValidatorStore> {
185+
const validatorStore = new ValidatorStore(modules, valProposerConfig);
186+
187+
await Promise.all(initialSigners.map((signer) => validatorStore.addSigner(signer, valProposerConfig)));
188+
189+
return validatorStore;
190+
}
191+
169192
/** Return all known indices from the validatorStore pubkeys */
170193
getAllLocalIndices(): ValidatorIndex[] {
171194
return this.indicesService.getAllLocalIndices();
@@ -282,18 +305,19 @@ export class ValidatorStore {
282305
return proposerConfig;
283306
}
284307

285-
addSigner(signer: Signer, valProposerConfig?: ValidatorProposerConfig): void {
308+
async addSigner(signer: Signer, valProposerConfig?: ValidatorProposerConfig): Promise<void> {
286309
const pubkey = getSignerPubkeyHex(signer);
287310
const proposerConfig = (valProposerConfig?.proposerConfig ?? {})[pubkey];
288311

289312
if (!this.validators.has(pubkey)) {
313+
// Doppelganger registration must be done before adding validator to signers
314+
await this.doppelgangerService?.registerValidator(pubkey);
315+
290316
this.pubkeysToDiscover.push(pubkey);
291317
this.validators.set(pubkey, {
292318
signer,
293319
...proposerConfig,
294320
});
295-
296-
this.doppelgangerService?.registerValidator(pubkey);
297321
}
298322
}
299323

packages/validator/src/slashingProtection/attestation/index.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {BLSPubkey} from "@lodestar/types";
1+
import {BLSPubkey, Epoch} from "@lodestar/types";
22
import {isEqualNonZeroRoot, minEpoch} from "../utils.js";
33
import {MinMaxSurround, SurroundAttestationError, SurroundAttestationErrorCode} from "../minMaxSurround/index.js";
44
import {SlashingProtectionAttestation} from "../types.js";
@@ -133,6 +133,13 @@ export class SlashingProtectionAttestationService {
133133
await this.minMaxSurround.insertAttestation(pubKey, attestation);
134134
}
135135

136+
/**
137+
* Retrieve an attestation from the slashing protection database for a given `pubkey` and `epoch`
138+
*/
139+
async getAttestationForEpoch(pubkey: BLSPubkey, epoch: Epoch): Promise<SlashingProtectionAttestation | null> {
140+
return this.attestationByTarget.get(pubkey, epoch);
141+
}
142+
136143
/**
137144
* Interchange import / export functionality
138145
*/

packages/validator/src/slashingProtection/index.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {toHexString} from "@chainsafe/ssz";
2-
import {BLSPubkey, Root} from "@lodestar/types";
2+
import {BLSPubkey, Epoch, Root} from "@lodestar/types";
33
import {Logger} from "@lodestar/utils";
44
import {LodestarValidatorDatabaseController} from "../types.js";
55
import {uniqueVectorArr} from "../slashingProtection/utils.js";
@@ -56,6 +56,10 @@ export class SlashingProtection implements ISlashingProtection {
5656
await this.attestationService.checkAndInsertAttestation(pubKey, attestation);
5757
}
5858

59+
async hasAttestedInEpoch(pubKey: BLSPubkey, epoch: Epoch): Promise<boolean> {
60+
return (await this.attestationService.getAttestationForEpoch(pubKey, epoch)) !== null;
61+
}
62+
5963
async importInterchange(interchange: Interchange, genesisValidatorsRoot: Root, logger?: Logger): Promise<void> {
6064
const {data} = parseInterchange(interchange, genesisValidatorsRoot);
6165
for (const validator of data) {

packages/validator/src/slashingProtection/interface.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {BLSPubkey, Root} from "@lodestar/types";
1+
import {BLSPubkey, Epoch, Root} from "@lodestar/types";
22
import {Logger} from "@lodestar/utils";
33
import {Interchange, InterchangeFormatVersion} from "./interchange/types.js";
44
import {SlashingProtectionBlock, SlashingProtectionAttestation} from "./types.js";
@@ -13,6 +13,11 @@ export interface ISlashingProtection {
1313
*/
1414
checkAndInsertAttestation(pubKey: BLSPubkey, attestation: SlashingProtectionAttestation): Promise<void>;
1515

16+
/**
17+
* Check whether a validator as identified by `pubKey` has attested in the specified `epoch`
18+
*/
19+
hasAttestedInEpoch(pubKey: BLSPubkey, epoch: Epoch): Promise<boolean>;
20+
1621
importInterchange(interchange: Interchange, genesisValidatorsRoot: Uint8Array | Root, logger?: Logger): Promise<void>;
1722
exportInterchange(
1823
genesisValidatorsRoot: Uint8Array | Root,

0 commit comments

Comments
 (0)