diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index 29959f871a5a..8500de429556 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -1,5 +1,5 @@ import {ContainerType} from "@chainsafe/ssz"; -import {ssz, stringType} from "@lodestar/types"; +import {Epoch, phase0, ssz, stringType} from "@lodestar/types"; import {ApiClientResponse} from "../interfaces.js"; import {HttpStatusCode} from "../utils/client/httpStatusCode.js"; import { @@ -223,6 +223,27 @@ export type Api = { HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND > >; + + /** + * Create a signed voluntary exit message for an active validator, identified by a public key known to the validator + * client. This endpoint returns a `SignedVoluntaryExit` object, which can be used to initiate voluntary exit via the + * beacon node's [submitPoolVoluntaryExit](https://ethereum.github.io/beacon-APIs/#/Beacon/submitPoolVoluntaryExit) endpoint. + * + * @param pubkey Public key of an active validator known to the validator client + * @param epoch Minimum epoch for processing exit. Defaults to the current epoch if not set + * @returns Signed voluntary exit message + * + * https://github.com/ethereum/keymanager-APIs/blob/7105e749e11dd78032ea275cc09bf62ecd548fca/keymanager-oapi.yaml + */ + signVoluntaryExit( + pubkey: PubkeyHex, + epoch?: Epoch + ): Promise< + ApiClientResponse< + {[HttpStatusCode.OK]: {data: phase0.SignedVoluntaryExit}}, + HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND + > + >; }; export const routesData: RoutesData = { @@ -241,6 +262,8 @@ export const routesData: RoutesData = { getGasLimit: {url: "/eth/v1/validator/{pubkey}/gas_limit", method: "GET"}, setGasLimit: {url: "/eth/v1/validator/{pubkey}/gas_limit", method: "POST", statusOk: 202}, deleteGasLimit: {url: "/eth/v1/validator/{pubkey}/gas_limit", method: "DELETE", statusOk: 204}, + + signVoluntaryExit: {url: "/eth/v1/validator/{pubkey}/voluntary_exit", method: "POST"}, }; /* eslint-disable @typescript-eslint/naming-convention */ @@ -271,6 +294,8 @@ export type ReqTypes = { getGasLimit: {params: {pubkey: string}}; setGasLimit: {params: {pubkey: string}; body: {gas_limit: string}}; deleteGasLimit: {params: {pubkey: string}}; + + signVoluntaryExit: {params: {pubkey: string}; query: {epoch?: number}}; }; export function getReqSerializers(): ReqSerializers { @@ -344,6 +369,14 @@ export function getReqSerializers(): ReqSerializers { params: {pubkey: Schema.StringRequired}, }, }, + signVoluntaryExit: { + writeReq: (pubkey, epoch) => ({params: {pubkey}, query: {epoch}}), + parseReq: ({params: {pubkey}, query: {epoch}}) => [pubkey, epoch], + schema: { + params: {pubkey: Schema.StringRequired}, + query: {epoch: Schema.Uint}, + }, + }, }; } @@ -367,6 +400,7 @@ export function getReturnTypes(): ReturnTypes { {jsonCase: "eth2"} ) ), + signVoluntaryExit: ContainerData(ssz.phase0.SignedVoluntaryExit), }; } diff --git a/packages/api/test/unit/keymanager/testData.ts b/packages/api/test/unit/keymanager/testData.ts index 50bca8d2fe01..3be3896b7147 100644 --- a/packages/api/test/unit/keymanager/testData.ts +++ b/packages/api/test/unit/keymanager/testData.ts @@ -1,3 +1,4 @@ +import {ssz} from "@lodestar/types"; import { Api, DeleteRemoteKeyStatus, @@ -80,4 +81,8 @@ export const testData: GenericServerTestCases = { args: [pubkeyRand], res: undefined, }, + signVoluntaryExit: { + args: [pubkeyRand, 1], + res: {data: ssz.phase0.SignedVoluntaryExit.defaultValue()}, + }, }; diff --git a/packages/cli/src/cmds/validator/keymanager/impl.ts b/packages/cli/src/cmds/validator/keymanager/impl.ts index 824ed8f124e6..fc9b1e127a40 100644 --- a/packages/cli/src/cmds/validator/keymanager/impl.ts +++ b/packages/cli/src/cmds/validator/keymanager/impl.ts @@ -15,6 +15,7 @@ import { } from "@lodestar/api/keymanager"; import {Interchange, SignerType, Validator} from "@lodestar/validator"; import {ServerApi} from "@lodestar/api"; +import {Epoch} from "@lodestar/types"; import {isValidHttpUrl} from "@lodestar/utils"; import {getPubkeyHexFromKeystore, isValidatePubkeyHex} from "../../../util/format.js"; import {parseFeeRecipient} from "../../../util/index.js"; @@ -363,6 +364,16 @@ export class KeymanagerApi implements Api { data: results, }; } + + /** + * Create and sign a voluntary exit message for an active validator + */ + async signVoluntaryExit(pubkey: PubkeyHex, epoch?: Epoch): ReturnType { + if (!isValidatePubkeyHex(pubkey)) { + throw Error(`Invalid pubkey ${pubkey}`); + } + return {data: await this.validator.signVoluntaryExit(pubkey, epoch)}; + } } /** diff --git a/packages/cli/test/e2e/signVoluntaryExitFromApi.test.ts b/packages/cli/test/e2e/signVoluntaryExitFromApi.test.ts new file mode 100644 index 000000000000..6b8b5b04d863 --- /dev/null +++ b/packages/cli/test/e2e/signVoluntaryExitFromApi.test.ts @@ -0,0 +1,127 @@ +import path from "node:path"; +import {expect} from "chai"; +import {rimraf} from "rimraf"; +import {ApiError, getClient, routes} from "@lodestar/api"; +import {Api as KeymanagerApi, getClient as getKeymanagerClient} from "@lodestar/api/keymanager"; +import {config} from "@lodestar/config/default"; +import {interopSecretKey} from "@lodestar/state-transition"; +import {gracefullyStopChildProcess, spawnCliCommand} from "@lodestar/test-utils"; +import {phase0} from "@lodestar/types"; +import {retry} from "@lodestar/utils"; +import {testFilesDir} from "../utils.js"; + +describe("sign voluntary exit from api", function () { + this.timeout("60s"); + + const dataDir = path.join(testFilesDir, "sign-voluntary-exit-test"); + + before("clean dataDir", () => { + rimraf.sync(dataDir); + }); + + let beaconClient: routes.beacon.Api; + let keymanagerClient: KeymanagerApi; + + let stopDevProc = async (): Promise => {}; + + before("start dev node with keymanager", async () => { + const keymanagerPort = 38012; + const beaconPort = 39012; + + const devProc = await spawnCliCommand( + "packages/cli/bin/lodestar.js", + [ + // ⏎ + "dev", + `--dataDir=${dataDir}`, + "--genesisValidators=8", + "--startValidators=0..7", + "--rest", + `--rest.port=${beaconPort}`, + // Speed up test to make genesis happen faster + "--params.SECONDS_PER_SLOT=2", + // Allow voluntary exists to be valid immediately + "--params.SHARD_COMMITTEE_PERIOD=0", + // Enable keymanager API + "--keymanager", + `--keymanager.port=${keymanagerPort}`, + // Disable bearer token auth to simplify testing + "--keymanager.authEnabled=false", + ], + {pipeStdioToParent: false, logPrefix: "dev"} + ); + + // Exit early if process exits + devProc.on("exit", (code) => { + if (code !== null && code > 0) { + throw new Error(`devProc process exited with code ${code}`); + } + }); + + // To cleanup the event stream connection + const httpClientController = new AbortController(); + + beaconClient = getClient( + {baseUrl: `http://127.0.0.1:${beaconPort}`, getAbortSignal: () => httpClientController.signal}, + {config} + ).beacon; + + keymanagerClient = getKeymanagerClient( + {baseUrl: `http://127.0.0.1:${keymanagerPort}`, getAbortSignal: () => httpClientController.signal}, + {config} + ); + + // Wait for beacon node API to be available + genesis + await retry( + async () => { + const head = await beaconClient.getBlockHeader("head"); + ApiError.assert(head); + if (head.response.data.header.message.slot < 1) throw Error("pre-genesis"); + }, + {retryDelay: 1000, retries: 20} + ); + + stopDevProc = async (): Promise => { + devProc.removeAllListeners("exit"); + httpClientController.abort(); + await gracefullyStopChildProcess(devProc, 3000); + }; + }); + + after(stopDevProc); + + const indexToExit = 0; + const pubkeyToExit = interopSecretKey(indexToExit).toPublicKey().toHex(); + + let signedVoluntaryExit: phase0.SignedVoluntaryExit; + + it("1. create signed voluntary exit message from API", async () => { + const res = await keymanagerClient.signVoluntaryExit(pubkeyToExit); + ApiError.assert(res); + signedVoluntaryExit = res.response.data; + + expect(signedVoluntaryExit.message.epoch).to.equal(0); + expect(signedVoluntaryExit.message.validatorIndex).to.equal(indexToExit); + expect(signedVoluntaryExit.signature).to.not.be.undefined; + }); + + it("2. submit signed voluntary exit message to beacon node", async () => { + ApiError.assert(await beaconClient.submitPoolVoluntaryExit(signedVoluntaryExit)); + }); + + it("3. confirm validator exited as expected", async () => { + await retry( + async () => { + const res = await beaconClient.getStateValidator("head", pubkeyToExit); + ApiError.assert(res); + if (res.response.data.status !== "active_exiting") { + throw Error("Validator not exiting"); + } else { + // eslint-disable-next-line no-console + console.log(`Confirmed validator ${pubkeyToExit} = ${res.response.data.status}`); + } + }, + {retryDelay: 1000, retries: 20} + ); + }); +}); diff --git a/packages/validator/src/validator.ts b/packages/validator/src/validator.ts index 50d4840694be..01a01b2afa20 100644 --- a/packages/validator/src/validator.ts +++ b/packages/validator/src/validator.ts @@ -1,5 +1,5 @@ import {toHexString} from "@chainsafe/ssz"; -import {BLSPubkey, ssz} from "@lodestar/types"; +import {BLSPubkey, phase0, ssz} from "@lodestar/types"; import {createBeaconConfig, BeaconConfig, ChainForkConfig} from "@lodestar/config"; import {Genesis} from "@lodestar/types/phase0"; import {Logger} from "@lodestar/utils"; @@ -245,6 +245,17 @@ export class Validator { * Perform a voluntary exit for the given validator by its key. */ async voluntaryExit(publicKey: string, exitEpoch?: number): Promise { + const signedVoluntaryExit = await this.signVoluntaryExit(publicKey, exitEpoch); + + ApiError.assert(await this.api.beacon.submitPoolVoluntaryExit(signedVoluntaryExit)); + + this.logger.info(`Submitted voluntary exit for ${publicKey} to the network`); + } + + /** + * Create a signed voluntary exit message for the given validator by its key. + */ + async signVoluntaryExit(publicKey: string, exitEpoch?: number): Promise { const res = await this.api.beacon.getStateValidators("head", {id: [publicKey]}); ApiError.assert(res, "Can not fetch state validators from beacon node"); @@ -258,10 +269,7 @@ export class Validator { exitEpoch = computeEpochAtSlot(getCurrentSlot(this.config, this.clock.genesisTime)); } - const signedVoluntaryExit = await this.validatorStore.signVoluntaryExit(publicKey, stateValidator.index, exitEpoch); - ApiError.assert(await this.api.beacon.submitPoolVoluntaryExit(signedVoluntaryExit)); - - this.logger.info(`Submitted voluntary exit for ${publicKey} to the network`); + return this.validatorStore.signVoluntaryExit(publicKey, stateValidator.index, exitEpoch); } private async fetchBeaconHealth(): Promise {