Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: restart aware doppelganger protection #6012

Merged
merged 15 commits into from
Oct 17, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions packages/cli/src/cmds/validator/keymanager/impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export class KeymanagerApi implements Api {

decryptKeystores.queue(
{keystoreStr, password},
(secretKeyBytes: Uint8Array) => {
async (secretKeyBytes: Uint8Array) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decryptKeystores.queue does no handle promise rejections. Why not decrypt all keystores first, accumulate the result of this callbacks to the queue, then on the main function body run the code below that can throw / reject?

Copy link
Member Author

@nflaig nflaig Oct 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decryptKeystores.queue does no handle promise rejections.

That's not an issue due to how promise chaining works. In any case sync or async this will be handled by onError handler

I was more concerned about the success case, i.e. does await decryptKeystores.completed(); properly wait for all promises to be resolved but this is the case as well. I tested both those cases manually, also existing tests should cover that.

Why not decrypt all keystores first, accumulate the result of this callbacks to the queue

Don't we lose a lot of concurrency by doing that? Decrypt keystores is CPU heavy while write keystores and add signers are pretty lightweight I/O operations, I think it is good that we run that in parallel.

Also if we assume an import of 100+ keys it potentially takes a few minutes, best to add signers to validator store asap.

const secretKey = bls.SecretKey.fromBytes(secretKeyBytes);

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

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

statuses[i] = {status: ImportStatus.imported};
},
Expand Down Expand Up @@ -292,7 +292,7 @@ export class KeymanagerApi implements Api {
async importRemoteKeys(
remoteSigners: Pick<SignerDefinition, "pubkey" | "url">[]
): ReturnType<Api["importRemoteKeys"]> {
const results = remoteSigners.map(({pubkey, url}): ResponseStatus<ImportRemoteKeyStatus> => {
const importPromises = remoteSigners.map(async ({pubkey, url}): Promise<ResponseStatus<ImportRemoteKeyStatus>> => {
dapplion marked this conversation as resolved.
Show resolved Hide resolved
try {
if (!isValidatePubkeyHex(pubkey)) {
throw Error(`Invalid pubkey ${pubkey}`);
Expand All @@ -308,7 +308,7 @@ export class KeymanagerApi implements Api {

// Else try to add it

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

this.persistedKeysBackend.writeRemoteKey({
pubkey,
Expand All @@ -325,7 +325,7 @@ export class KeymanagerApi implements Api {
});

return {
data: results,
data: await Promise.all(importPromises),
};
}

Expand Down
58 changes: 44 additions & 14 deletions packages/validator/src/services/doppelgangerService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {fromHexString} from "@chainsafe/ssz";
import {Epoch, ValidatorIndex} from "@lodestar/types";
import {Api, ApiError, routes} from "@lodestar/api";
import {Logger, sleep} from "@lodestar/utils";
import {Logger, prettyBytes, sleep} from "@lodestar/utils";
import {computeStartSlotAtEpoch} from "@lodestar/state-transition";
import {ISlashingProtection} from "../slashingProtection/index.js";
import {ProcessShutdownCallback, PubkeyHex} from "../types.js";
import {IClock} from "../util/index.js";
import {Metrics} from "../metrics.js";
Expand All @@ -10,7 +12,8 @@ import {IndicesService} from "./indices.js";
// The number of epochs that must be checked before we assume that there are
// no other duplicate validators on the network
const DEFAULT_REMAINING_DETECTION_EPOCHS = 1;
const REMAINING_EPOCHS_IF_DOPPLEGANGER = Infinity;
const REMAINING_EPOCHS_IF_DOPPELGANGER = Infinity;
const REMAINING_EPOCHS_IF_SKIPPED = 0;

/** Liveness responses for a given epoch */
type EpochLivenessData = {
Expand All @@ -24,13 +27,13 @@ export type DoppelgangerState = {
};

export enum DoppelgangerStatus {
// This pubkey is known to the doppelganger service and has been verified safe
/** This pubkey is known to the doppelganger service and has been verified safe */
VerifiedSafe = "VerifiedSafe",
// This pubkey is known to the doppelganger service but has not been verified safe
/** This pubkey is known to the doppelganger service but has not been verified safe */
Unverified = "Unverified",
// This pubkey is unknown to the doppelganger service
/** This pubkey is unknown to the doppelganger service */
Unknown = "Unknown",
// This pubkey has been detected to be active on the network
/** This pubkey has been detected to be active on the network */
DoppelgangerDetected = "DoppelgangerDetected",
}

Expand All @@ -42,6 +45,7 @@ export class DoppelgangerService {
private readonly clock: IClock,
private readonly api: Api,
private readonly indicesService: IndicesService,
private readonly slashingProtection: ISlashingProtection,
private readonly processShutdownCallback: ProcessShutdownCallback,
private readonly metrics: Metrics | null
) {
Expand All @@ -54,16 +58,42 @@ export class DoppelgangerService {
this.logger.info("Doppelganger protection enabled", {detectionEpochs: DEFAULT_REMAINING_DETECTION_EPOCHS});
}

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

// Log here to alert that validation won't be active until remainingEpochs == 0
if (remainingEpochs > 0) {
this.logger.info("Registered validator for doppelganger", {remainingEpochs, nextEpochToCheck, pubkeyHex});
const previousEpoch = currentEpoch - 1;
const attestedInPreviousEpoch = await this.slashingProtection.hasAttestedInEpoch(
fromHexString(pubkeyHex),
previousEpoch
);

if (attestedInPreviousEpoch) {
Copy link
Member Author

@nflaig nflaig Oct 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an edge case when importing a validator via keymanager API, it is possible to also import the slashing protection interchange file. This will create an attestation record in the database and could cause doppelganger protection to be skipped if import happens within in the next epoch.

I don't see this as a huge risk because exporting the slashing protection interchange from a running client is only possible while also deleting the key from that client, there is no risk of getting slashing due to doppelganger.
It also assumes slashing protection was exported / imported within short time period, likely in an automated setup which aims to achieve no downtime when switching clients and therefore doppelganger protection would not be enabled anyways.

Considering this, I think there is no risk in the currently used approach and it is safe to skip doppelganger in any scenario if an attestation from previous epoch exists in the slashing protection db.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider committing this explanation into the code

Copy link
Member Author

@nflaig nflaig Oct 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mostly added this here to get a second opinion on this if my reasoning is sound. But might be a good idea to add this to the issue description for better visibility instead of here.

The issue is already referenced in the code

if (attestedInPreviousEpoch) {
// It is safe to skip doppelganger detection
// https://github.com/ChainSafe/lodestar/issues/5856

Will refine the issue description a bit

remainingEpochs = REMAINING_EPOCHS_IF_SKIPPED;
this.logger.info(
"Doppelganger detection skipped for validator, previous epoch attestation exists in database",
{
pubkey: prettyBytes(pubkeyHex),
previousEpoch,
}
);
} else {
this.logger.info("Registered validator for doppelganger", {
pubkey: prettyBytes(pubkeyHex),
remainingEpochs,
nextEpochToCheck,
});
}
} else {
this.logger.info("Doppelganger detection skipped for validator, initialized before genesis", {
pubkey: prettyBytes(pubkeyHex),
currentEpoch,
});
}

this.doppelgangerStateByPubkey.set(pubkeyHex, {
Expand Down Expand Up @@ -180,7 +210,7 @@ export class DoppelgangerService {
}

if (state.nextEpochToCheck <= epoch) {
// Doppleganger detected
// Doppelganger detected
violators.push(response.index);
}
}
Expand All @@ -189,7 +219,7 @@ export class DoppelgangerService {
if (violators.length > 0) {
// If a single doppelganger is detected, enable doppelganger checks on all validators forever
for (const state of this.doppelgangerStateByPubkey.values()) {
state.remainingEpochs = Infinity;
state.remainingEpochs = REMAINING_EPOCHS_IF_DOPPELGANGER;
}

this.logger.error(
Expand Down Expand Up @@ -225,9 +255,9 @@ export class DoppelgangerService {

const {remainingEpochs, nextEpochToCheck} = state;
if (remainingEpochs <= 0) {
this.logger.info("Doppelganger detection complete", {index: response.index});
this.logger.info("Doppelganger detection complete", {index: response.index, epoch: currentEpoch});
} else {
this.logger.info("Found no doppelganger", {remainingEpochs, nextEpochToCheck, index: response.index});
this.logger.info("Found no doppelganger", {index: response.index, remainingEpochs, nextEpochToCheck});
}
}
}
Expand All @@ -253,7 +283,7 @@ function getStatus(state: DoppelgangerState | undefined): DoppelgangerStatus {
return DoppelgangerStatus.Unknown;
} else if (state.remainingEpochs <= 0) {
return DoppelgangerStatus.VerifiedSafe;
} else if (state.remainingEpochs === REMAINING_EPOCHS_IF_DOPPLEGANGER) {
} else if (state.remainingEpochs === REMAINING_EPOCHS_IF_DOPPELGANGER) {
return DoppelgangerStatus.DoppelgangerDetected;
} else {
return DoppelgangerStatus.Unverified;
Expand Down
58 changes: 41 additions & 17 deletions packages/validator/src/services/validatorStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ export type ValidatorProposerConfig = {
defaultConfig: ProposerConfig;
};

export type ValidatorStoreModules = {
config: BeaconConfig;
slashingProtection: ISlashingProtection;
indicesService: IndicesService;
doppelgangerService: DoppelgangerService | null;
metrics: Metrics | null;
};

/**
* This cache stores SignedValidatorRegistrationV1 data for a validator so that
* we do not create and send new registration objects to avoid DOSing the builder
Expand Down Expand Up @@ -130,21 +138,25 @@ export const defaultOptions = {
* Service that sets up and handles validator attester duties.
*/
export class ValidatorStore {
private readonly config: BeaconConfig;
private readonly slashingProtection: ISlashingProtection;
private readonly indicesService: IndicesService;
private readonly doppelgangerService: DoppelgangerService | null;
private readonly metrics: Metrics | null;

private readonly validators = new Map<PubkeyHex, ValidatorData>();
/** Initially true because there are no validators */
private pubkeysToDiscover: PubkeyHex[] = [];
private readonly defaultProposerConfig: DefaultProposerConfig;

constructor(
private readonly config: BeaconConfig,
private readonly slashingProtection: ISlashingProtection,
private readonly indicesService: IndicesService,
private readonly doppelgangerService: DoppelgangerService | null,
private readonly metrics: Metrics | null,
initialSigners: Signer[],
valProposerConfig: ValidatorProposerConfig = {defaultConfig: {}, proposerConfig: {}},
private readonly genesisValidatorRoot: Root
) {
constructor(modules: ValidatorStoreModules, valProposerConfig: ValidatorProposerConfig) {
const {config, slashingProtection, indicesService, doppelgangerService, metrics} = modules;
this.config = config;
this.slashingProtection = slashingProtection;
this.indicesService = indicesService;
this.doppelgangerService = doppelgangerService;
this.metrics = metrics;

const defaultConfig = valProposerConfig.defaultConfig;
this.defaultProposerConfig = {
graffiti: defaultConfig.graffiti ?? "",
Expand All @@ -157,15 +169,26 @@ export class ValidatorStore {
},
};

for (const signer of initialSigners) {
this.addSigner(signer, valProposerConfig);
}

if (metrics) {
metrics.signers.addCollect(() => metrics.signers.set(this.validators.size));
}
}

/**
* Create a validator store with initial signers
*/
static async init(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validator store initialization is now async, see #5856 (comment)

modules: ValidatorStoreModules,
initialSigners: Signer[],
valProposerConfig: ValidatorProposerConfig = {defaultConfig: {}, proposerConfig: {}}
): Promise<ValidatorStore> {
const validatorStore = new ValidatorStore(modules, valProposerConfig);

await Promise.all(initialSigners.map((signer) => validatorStore.addSigner(signer, valProposerConfig)));

return validatorStore;
}

/** Return all known indices from the validatorStore pubkeys */
getAllLocalIndices(): ValidatorIndex[] {
return this.indicesService.getAllLocalIndices();
Expand Down Expand Up @@ -282,18 +305,19 @@ export class ValidatorStore {
return proposerConfig;
}

addSigner(signer: Signer, valProposerConfig?: ValidatorProposerConfig): void {
async addSigner(signer: Signer, valProposerConfig?: ValidatorProposerConfig): Promise<void> {
const pubkey = getSignerPubkeyHex(signer);
const proposerConfig = (valProposerConfig?.proposerConfig ?? {})[pubkey];

if (!this.validators.has(pubkey)) {
// Doppelganger registration must be done before adding validator to signers
await this.doppelgangerService?.registerValidator(pubkey);

this.pubkeysToDiscover.push(pubkey);
this.validators.set(pubkey, {
signer,
...proposerConfig,
});

this.doppelgangerService?.registerValidator(pubkey);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {BLSPubkey} from "@lodestar/types";
import {BLSPubkey, Epoch} from "@lodestar/types";
import {isEqualNonZeroRoot, minEpoch} from "../utils.js";
import {MinMaxSurround, SurroundAttestationError, SurroundAttestationErrorCode} from "../minMaxSurround/index.js";
import {SlashingProtectionAttestation} from "../types.js";
Expand Down Expand Up @@ -133,6 +133,13 @@ export class SlashingProtectionAttestationService {
await this.minMaxSurround.insertAttestation(pubKey, attestation);
}

/**
* Retrieve an attestation from the slashing protection database for a given `pubkey` and `epoch`
*/
async getAttestationForEpoch(pubkey: BLSPubkey, epoch: Epoch): Promise<SlashingProtectionAttestation | null> {
return this.attestationByTarget.get(pubkey, epoch);
}

/**
* Interchange import / export functionality
*/
Expand Down
6 changes: 5 additions & 1 deletion packages/validator/src/slashingProtection/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {toHexString} from "@chainsafe/ssz";
import {BLSPubkey, Root} from "@lodestar/types";
import {BLSPubkey, Epoch, Root} from "@lodestar/types";
import {Logger} from "@lodestar/utils";
import {LodestarValidatorDatabaseController} from "../types.js";
import {uniqueVectorArr} from "../slashingProtection/utils.js";
Expand Down Expand Up @@ -56,6 +56,10 @@ export class SlashingProtection implements ISlashingProtection {
await this.attestationService.checkAndInsertAttestation(pubKey, attestation);
}

async hasAttestedInEpoch(pubKey: BLSPubkey, epoch: Epoch): Promise<boolean> {
return (await this.attestationService.getAttestationForEpoch(pubKey, epoch)) !== null;
}

async importInterchange(interchange: Interchange, genesisValidatorsRoot: Root, logger?: Logger): Promise<void> {
const {data} = parseInterchange(interchange, genesisValidatorsRoot);
for (const validator of data) {
Expand Down
7 changes: 6 additions & 1 deletion packages/validator/src/slashingProtection/interface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {BLSPubkey, Root} from "@lodestar/types";
import {BLSPubkey, Epoch, Root} from "@lodestar/types";
import {Logger} from "@lodestar/utils";
import {Interchange, InterchangeFormatVersion} from "./interchange/types.js";
import {SlashingProtectionBlock, SlashingProtectionAttestation} from "./types.js";
Expand All @@ -13,6 +13,11 @@ export interface ISlashingProtection {
*/
checkAndInsertAttestation(pubKey: BLSPubkey, attestation: SlashingProtectionAttestation): Promise<void>;

/**
* Check whether a validator as identified by `pubKey` has attested in the specified `epoch`
*/
hasAttestedInEpoch(pubKey: BLSPubkey, epoch: Epoch): Promise<boolean>;

importInterchange(interchange: Interchange, genesisValidatorsRoot: Uint8Array | Root, logger?: Logger): Promise<void>;
exportInterchange(
genesisValidatorsRoot: Uint8Array | Root,
Expand Down
Loading