diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index cb3191d07a7d..503a3e268cd0 100644 --- a/packages/cli/src/cmds/validator/handler.ts +++ b/packages/cli/src/cmds/validator/handler.ts @@ -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)" ); } } @@ -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 ); diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index 5ca59280250f..178405526fde 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -58,6 +58,7 @@ export type IValidatorCliArgs = AccountValidatorArgs & "externalSigner.url"?: string; "externalSigner.pubkeys"?: string[]; "externalSigner.fetch"?: boolean; + "externalSigner.fetchInterval"?: number; distributed?: boolean; @@ -303,15 +304,16 @@ export const validatorOptions: CliCommandOptions = { 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", @@ -322,15 +324,23 @@ export const validatorOptions: CliCommandOptions = { .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 diff --git a/packages/validator/src/services/externalSignerSync.ts b/packages/validator/src/services/externalSignerSync.ts new file mode 100644 index 000000000000..e41b38d6c43f --- /dev/null +++ b/packages/validator/src/services/externalSignerSync.ts @@ -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 { + // 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); + } +} diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 0c3c0b33cea8..bb111d1798f2 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -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, diff --git a/packages/validator/src/validator.ts b/packages/validator/src/validator.ts index deff99ee93f0..bfbd0e7052ff 100644 --- a/packages/validator/src/validator.ts +++ b/packages/validator/src/validator.ts @@ -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"; @@ -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 @@ -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