Skip to content

Commit

Permalink
feat: keymanager API to create signed voluntary exit message (#5947)
Browse files Browse the repository at this point in the history
* feat: keymanager API to create signed voluntary exit message

* Run e2e tests in a single step

* Remove before hook to clean up dataDir

* Remove abort controller
  • Loading branch information
nflaig authored Sep 11, 2023
1 parent bbfdcb4 commit 1f7e73b
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 6 deletions.
36 changes: 35 additions & 1 deletion packages/api/src/keymanager/routes.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<Api> = {
Expand All @@ -241,6 +262,8 @@ export const routesData: RoutesData<Api> = {
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 */
Expand Down Expand Up @@ -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<Api, ReqTypes> {
Expand Down Expand Up @@ -344,6 +369,14 @@ export function getReqSerializers(): ReqSerializers<Api, ReqTypes> {
params: {pubkey: Schema.StringRequired},
},
},
signVoluntaryExit: {
writeReq: (pubkey, epoch) => ({params: {pubkey}, query: epoch !== undefined ? {epoch} : {}}),
parseReq: ({params: {pubkey}, query: {epoch}}) => [pubkey, epoch],
schema: {
params: {pubkey: Schema.StringRequired},
query: {epoch: Schema.Uint},
},
},
};
}

Expand All @@ -367,6 +400,7 @@ export function getReturnTypes(): ReturnTypes<Api> {
{jsonCase: "eth2"}
)
),
signVoluntaryExit: ContainerData(ssz.phase0.SignedVoluntaryExit),
};
}

Expand Down
5 changes: 5 additions & 0 deletions packages/api/test/unit/keymanager/testData.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {ssz} from "@lodestar/types";
import {
Api,
DeleteRemoteKeyStatus,
Expand Down Expand Up @@ -80,4 +81,8 @@ export const testData: GenericServerTestCases<Api> = {
args: [pubkeyRand],
res: undefined,
},
signVoluntaryExit: {
args: [pubkeyRand, 1],
res: {data: ssz.phase0.SignedVoluntaryExit.defaultValue()},
},
};
11 changes: 11 additions & 0 deletions packages/cli/src/cmds/validator/keymanager/impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Api["signVoluntaryExit"]> {
if (!isValidatePubkeyHex(pubkey)) {
throw Error(`Invalid pubkey ${pubkey}`);
}
return {data: await this.validator.signVoluntaryExit(pubkey, epoch)};
}
}

/**
Expand Down
97 changes: 97 additions & 0 deletions packages/cli/test/e2e/voluntaryExitFromApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import path from "node:path";
import {expect} from "chai";
import {ApiError, getClient} from "@lodestar/api";
import {getClient as getKeymanagerClient} from "@lodestar/api/keymanager";
import {config} from "@lodestar/config/default";
import {interopSecretKey} from "@lodestar/state-transition";
import {spawnCliCommand} from "@lodestar/test-utils";
import {getMochaContext} from "@lodestar/test-utils/mocha";
import {retry} from "@lodestar/utils";
import {testFilesDir} from "../utils.js";

describe("voluntary exit from api", function () {
const testContext = getMochaContext(this);
this.timeout("60s");

it("Perform a voluntary exit", async () => {
// Start dev node with keymanager
const keymanagerPort = 38012;
const beaconPort = 39012;

const devProc = await spawnCliCommand(
"packages/cli/bin/lodestar.js",
[
// ⏎
"dev",
`--dataDir=${path.join(testFilesDir, "voluntary-exit-api-test")}`,
"--genesisValidators=8",
"--startValidators=0..7",
"--rest",
`--rest.port=${beaconPort}`,
`--beaconNodes=http://127.0.0.1:${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", testContext}
);

// Exit early if process exits
devProc.on("exit", (code) => {
if (code !== null && code > 0) {
throw new Error(`devProc process exited with code ${code}`);
}
});

const beaconClient = getClient({baseUrl: `http://127.0.0.1:${beaconPort}`}, {config}).beacon;
const keymanagerClient = getKeymanagerClient({baseUrl: `http://127.0.0.1:${keymanagerPort}`}, {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}
);

// 1. create signed voluntary exit message from keymanager
const exitEpoch = 0;
const indexToExit = 0;
const pubkeyToExit = interopSecretKey(indexToExit).toPublicKey().toHex();

const res = await keymanagerClient.signVoluntaryExit(pubkeyToExit, exitEpoch);
ApiError.assert(res);
const signedVoluntaryExit = res.response.data;

expect(signedVoluntaryExit.message.epoch).to.equal(exitEpoch);
expect(signedVoluntaryExit.message.validatorIndex).to.equal(indexToExit);
// Signature will be verified when submitting to beacon node
expect(signedVoluntaryExit.signature).to.not.be.undefined;

// 2. submit signed voluntary exit message to beacon node
ApiError.assert(await beaconClient.submitPoolVoluntaryExit(signedVoluntaryExit));

// 3. confirm validator status is 'active_exiting'
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}
);
});
});
18 changes: 13 additions & 5 deletions packages/validator/src/validator.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<void> {
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<phase0.SignedVoluntaryExit> {
const res = await this.api.beacon.getStateValidators("head", {id: [publicKey]});
ApiError.assert(res, "Can not fetch state validators from beacon node");

Expand All @@ -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<BeaconHealth> {
Expand Down

0 comments on commit 1f7e73b

Please sign in to comment.