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: keymanager API to create signed voluntary exit message #5947

Merged
merged 4 commits into from
Sep 11, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
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
Loading