Skip to content

Commit

Permalink
Synchronize keys between validator client and remote signer
Browse files Browse the repository at this point in the history
  • Loading branch information
nflaig committed Apr 15, 2024
1 parent 2dae605 commit e3b5466
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 7 deletions.
24 changes: 21 additions & 3 deletions packages/cli/src/cmds/validator/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,24 @@ export async function validatorHandler(args: IValidatorCliArgs & GlobalArgs): Pr

// Ensure the validator has at least one key
if (signers.length === 0) {
if (args["keymanager"]) {
logger.warn("No local keystores or remote signers found with current args, expecting to be added via keymanager");
if (args["keymanager"] && !args["externalSigner.fetch"]) {
logger.warn("No local keystores or remote keys found with current args, expecting to be added via keymanager");
} else if (!args["keymanager"] && args["externalSigner.fetch"]) {
logger.warn(
"No remote keys found with current args, expecting to be added to external signer and synced later on"
);
} else if (args["keymanager"] && args["externalSigner.fetch"]) {
logger.warn(
"No local keystores or remote keys found with current args, expecting to be added via keymanager or synced from external signer"
);
} else {
if (args["externalSigner.url"]) {
throw new YargsError(
"No remote keys found with current args, start with --externalSigner.fetch to automatically sync keys from external signer"
);
}
throw new YargsError(
"No local keystores and remote signers found with current args, start with --keymanager if intending to add them later (via keymanager)"
"No local keystores and remote keys found with current args, start with --keymanager if intending to add them later (via keymanager)"
);
}
}
Expand Down Expand Up @@ -172,6 +185,11 @@ export async function validatorHandler(args: IValidatorCliArgs & GlobalArgs): Pr
useProduceBlockV3: args.useProduceBlockV3,
broadcastValidation: parseBroadcastValidation(args.broadcastValidation),
blindedLocal: args.blindedLocal,
externalSigner: {
url: args["externalSigner.url"],
fetch: args["externalSigner.fetch"],
fetchInterval: args["externalSigner.fetchInterval"],
},
},
metrics
);
Expand Down
18 changes: 14 additions & 4 deletions packages/cli/src/cmds/validator/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export type IValidatorCliArgs = AccountValidatorArgs &
"externalSigner.url"?: string;
"externalSigner.pubkeys"?: string[];
"externalSigner.fetch"?: boolean;
"externalSigner.fetchInterval"?: number;

distributed?: boolean;

Expand Down Expand Up @@ -303,15 +304,16 @@ export const validatorOptions: CliCommandOptions<IValidatorCliArgs> = {
type: "boolean",
},

// Remote signer
// External signer

"externalSigner.url": {
description: "URL to connect to an external signing server",
type: "string",
group: "externalSignerUrl",
group: "externalSigner",
},

"externalSigner.pubkeys": {
implies: ["externalSigner.url"],
description:
"List of validator public keys used by an external signer. May also provide a single string of comma-separated public keys",
type: "array",
Expand All @@ -322,15 +324,23 @@ export const validatorOptions: CliCommandOptions<IValidatorCliArgs> = {
.map((item) => item.split(","))
.flat(1)
.map(ensure0xPrefix),
group: "externalSignerUrl",
group: "externalSigner",
},

"externalSigner.fetch": {
implies: ["externalSigner.url"],
conflicts: ["externalSigner.pubkeys"],
description:
"Fetch the list of public keys to validate from an external signer. Cannot be used in combination with `--externalSigner.pubkeys`",
type: "boolean",
group: "externalSignerUrl",
group: "externalSigner",
},

"externalSigner.fetchInterval": {
implies: ["externalSigner.fetch"],
description: "Interval in milliseconds between syncing keys from external signer, once per epoch by default",
type: "number",
group: "externalSigner",
},

// Distributed validator
Expand Down
81 changes: 81 additions & 0 deletions packages/validator/src/services/externalSignerSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import bls from "@chainsafe/bls";
import {CoordType} from "@chainsafe/bls/types";
import {fromHexString} from "@chainsafe/ssz";
import {BeaconConfig} from "@lodestar/config";
import {SLOTS_PER_EPOCH} from "@lodestar/params";
import {toSafePrintableUrl} from "@lodestar/utils";

import {LoggerVc} from "../util/index.js";
import {externalSignerGetKeys} from "../util/externalSignerClient.js";
import {ValidatorOptions} from "../validator.js";
import {SignerType, ValidatorStore} from "./validatorStore.js";

/**
* This service is responsible for keeping the keys managed by the connected
* external signer and the validator client in sync by adding newly discovered keys
* and removing no longer present keys on external signer from the validator store.
*/
export function pollExternalSignerPubkeys(
config: BeaconConfig,
logger: LoggerVc,
signal: AbortSignal,
validatorStore: ValidatorStore,
opts: ValidatorOptions
): void {
const {externalSigner = {}} = opts;

if (!externalSigner.url || !externalSigner.fetch) {
return; // Disabled
}

async function syncExternalSignerPubkeys(): Promise<void> {
// External signer URL is already validated earlier
const externalSignerUrl = externalSigner.url as string;
const printableUrl = toSafePrintableUrl(externalSignerUrl);

try {
logger.debug("Syncing keys from external signer", {url: printableUrl});
const externalPubkeys = await externalSignerGetKeys(externalSignerUrl);
assertValidPubkeysHex(externalPubkeys);
logger.debug("Retrieved public keys from external signer", {url: printableUrl, count: externalPubkeys.length});

const localPubkeys = validatorStore.getRemoteSignerPubkeys(externalSignerUrl);
logger.debug("Local public keys stored for external signer", {url: printableUrl, count: localPubkeys.length});

// Add newly discovered public keys to remote signers
const localPubkeysSet = new Set(localPubkeys);
for (const pubkey of externalPubkeys) {
if (!localPubkeysSet.has(pubkey)) {
await validatorStore.addSigner({type: SignerType.Remote, pubkey, url: externalSignerUrl});
logger.info("Added remote signer", {url: printableUrl, pubkey});
}
}

// Remove remote signers that are no longer present on external signer
const externalPubkeysSet = new Set(externalPubkeys);
for (const pubkey of localPubkeys) {
if (!externalPubkeysSet.has(pubkey)) {
validatorStore.removeSigner(pubkey);
logger.info("Removed remote signer", {url: printableUrl, pubkey});
}
}
} catch (e) {
logger.error("Failed to sync keys from external signer", {url: printableUrl}, e as Error);
}
}

const syncInterval = setInterval(
syncExternalSignerPubkeys,
externalSigner?.fetchInterval ??
// Once per epoch by default
SLOTS_PER_EPOCH * config.SECONDS_PER_SLOT * 1000
);
signal.addEventListener("abort", () => clearInterval(syncInterval), {once: true});
}

function assertValidPubkeysHex(pubkeysHex: string[]): void {
for (const pubkeyHex of pubkeysHex) {
const pubkeyBytes = fromHexString(pubkeyHex);
bls.PublicKey.fromBytes(pubkeyBytes, CoordType.jacobian, true);
}
}
10 changes: 10 additions & 0 deletions packages/validator/src/services/validatorStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,16 @@ export class ValidatorStore {
return this.validators.has(pubkeyHex);
}

getRemoteSignerPubkeys(signerUrl: string): PubkeyHex[] {
const pubkeysHex = [];
for (const validator of this.validators.values()) {
if (validator.signer.type === SignerType.Remote && validator.signer.url === signerUrl) {
pubkeysHex.push(validator.signer.pubkey);
}
}
return pubkeysHex;
}

async signBlock(
pubkey: BLSPubkey,
blindedOrFull: allForks.FullOrBlindedBeaconBlock,
Expand Down
7 changes: 7 additions & 0 deletions packages/validator/src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {AttestationService} from "./services/attestation.js";
import {IndicesService} from "./services/indices.js";
import {SyncCommitteeService} from "./services/syncCommittee.js";
import {pollPrepareBeaconProposer, pollBuilderValidatorRegistration} from "./services/prepareBeaconProposer.js";
import {pollExternalSignerPubkeys} from "./services/externalSignerSync.js";
import {Interchange, InterchangeFormatVersion, ISlashingProtection} from "./slashingProtection/index.js";
import {assertEqualParams, getLoggerVc, NotEqualParamsError} from "./util/index.js";
import {ChainHeaderTracker} from "./services/chainHeaderTracker.js";
Expand Down Expand Up @@ -59,6 +60,11 @@ export type ValidatorOptions = {
useProduceBlockV3?: boolean;
broadcastValidation?: routes.beacon.BroadcastValidation;
blindedLocal?: boolean;
externalSigner?: {
url?: string;
fetch?: boolean;
fetchInterval?: number;
};
};

// TODO: Extend the timeout, and let it be customizable
Expand Down Expand Up @@ -200,6 +206,7 @@ export class Validator {
);
pollPrepareBeaconProposer(config, loggerVc, api, clock, validatorStore, metrics);
pollBuilderValidatorRegistration(config, loggerVc, api, clock, validatorStore, metrics);
pollExternalSignerPubkeys(config, loggerVc, controller.signal, validatorStore, opts);

const emitter = new ValidatorEventEmitter();
// Validator event emitter can have more than 10 listeners in a normal course of operation
Expand Down

0 comments on commit e3b5466

Please sign in to comment.