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: support remote signer in voluntary exit command #6132

Merged
merged 18 commits into from
Dec 1, 2023
Merged
Show file tree
Hide file tree
Changes from 10 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
2 changes: 1 addition & 1 deletion packages/cli/src/cmds/validator/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ export const validatorOptions: CliCommandOptions<IValidatorCliArgs> = {

"externalSigner.fetch": {
conflicts: ["externalSigner.pubkeys"],
description: "Fetch then list of public keys to validate from an external signer",
description: "Fetch the list of public keys to validate from an external signer",
type: "boolean",
group: "externalSignerUrl",
},
Expand Down
58 changes: 41 additions & 17 deletions packages/cli/src/cmds/validator/voluntaryExit.ts
nflaig marked this conversation as resolved.
Show resolved Hide resolved
nflaig marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import inquirer from "inquirer";
import bls from "@chainsafe/bls";
import {
computeSigningRoot,
computeEpochAtSlot,
computeSigningRoot,
computeStartSlotAtEpoch,
getCurrentSlot,
} from "@lodestar/state-transition";
import {createBeaconConfig} from "@lodestar/config";
import {ssz, phase0} from "@lodestar/types";
import {phase0, ssz} from "@lodestar/types";
import {toHex} from "@lodestar/utils";
import {Signer, SignerLocal, SignerType} from "@lodestar/validator";
import {externalSignerPostSignature, SignableMessageType, Signer, SignerType} from "@lodestar/validator";
import {Api, ApiError, getClient} from "@lodestar/api";
import {ensure0xPrefix, CliCommand, YargsError} from "../../util/index.js";
import {CliCommand, ensure0xPrefix, YargsError} from "../../util/index.js";
import {GlobalArgs} from "../../options/index.js";
import {getBeaconConfigFromArgs} from "../../config/index.js";
import {IValidatorCliArgs} from "./options.js";
Expand All @@ -36,6 +37,12 @@ If no `pubkeys` are provided, it will exit all validators that have been importe
command: "validator voluntary-exit --network goerli --pubkeys 0xF00",
description: "Perform a voluntary exit for the validator who has a public key 0xF00",
},
{
command:
"validator voluntary-exit --network goerli --externalSigner.url=http://signer:9000 --externalSigner.pubkeys 0xF00",
eth2353 marked this conversation as resolved.
Show resolved Hide resolved
description:
"Perform a voluntary exit for the validator who has a public key 0xF00 and its secret key is on a remote signer",
},
],

options: {
Expand All @@ -46,7 +53,7 @@ If no `pubkeys` are provided, it will exit all validators that have been importe
},

pubkeys: {
description: "Public keys to exit, must be available as local signers",
description: "Public keys to exit",
type: "array",
string: true, // Ensures the pubkey string is not automatically converted to numbers
coerce: (pubkeys: string[]): string[] =>
Expand Down Expand Up @@ -82,9 +89,12 @@ If no `pubkeys` are provided, it will exit all validators that have been importe
// Select signers to exit
const signers = await getSignersFromArgs(args, network, {logger: console, signal: new AbortController().signal});
if (signers.length === 0) {
throw new YargsError(`No local keystores found with current args.
throw new YargsError(`No validators to exit found with current args.
Ensure --dataDir and --network match values used when importing keys via validator import
or alternatively, import keys by providing --importKeystores arg to voluntary-exit command.`);
or alternatively, import keys by providing --importKeystores arg to voluntary-exit command.
If attempting to exit validators on a remote signer, make sure values are provided for
the necessary --externalSigner options.
`);
}
const signersToExit = selectSignersToExit(args, signers);
const validatorsToExit = await resolveValidatorIndexes(client, signersToExit);
Expand All @@ -106,53 +116,67 @@ ${validatorsToExit.map((v) => `${v.pubkey} ${v.index} ${v.status}`).join("\n")}`
}

for (const [i, {index, signer, pubkey}] of validatorsToExit.entries()) {
const domain = config.getDomainForVoluntaryExit(computeStartSlotAtEpoch(exitEpoch));
const slot = computeStartSlotAtEpoch(exitEpoch);
const domain = config.getDomainForVoluntaryExit(slot);
const voluntaryExit: phase0.VoluntaryExit = {epoch: exitEpoch, validatorIndex: index};
const signingRoot = computeSigningRoot(ssz.phase0.VoluntaryExit, voluntaryExit, domain);

let signature;
switch (signer.type) {
case SignerType.Local:
signature = signer.secretKey.sign(signingRoot);
break;
case SignerType.Remote: {
const signatureHex = await externalSignerPostSignature(config, signer.url, pubkey, signingRoot, slot, {
data: voluntaryExit,
type: SignableMessageType.VOLUNTARY_EXIT,
});
signature = bls.Signature.fromHex(signatureHex);
break;
}
default:
throw new YargsError(`Unexpected signer type for ${pubkey}`);
}
ApiError.assert(
await client.beacon.submitPoolVoluntaryExit({
message: voluntaryExit,
signature: signer.secretKey.sign(signingRoot).toBytes(),
signature: signature.toBytes(),
})
);

console.log(`Submitted voluntary exit for ${pubkey} ${i + 1}/${signersToExit.length}`);
}
},
};

type SignerLocalPubkey = {signer: SignerLocal; pubkey: string};
type SignerPubkey = {signer: Signer; pubkey: string};

function selectSignersToExit(args: VoluntaryExitArgs, signers: Signer[]): SignerLocalPubkey[] {
function selectSignersToExit(args: VoluntaryExitArgs, signers: Signer[]): SignerPubkey[] {
const signersWithPubkey = signers.map((signer) => ({
signer,
pubkey: getSignerPubkeyHex(signer),
}));

if (args.pubkeys) {
const signersByPubkey = new Map<string, Signer>(signersWithPubkey.map(({pubkey, signer}) => [pubkey, signer]));
const selectedSigners: SignerLocalPubkey[] = [];
const selectedSigners: SignerPubkey[] = [];

for (const pubkey of args.pubkeys) {
const signer = signersByPubkey.get(pubkey);
if (!signer) {
throw new YargsError(`Unknown pubkey ${pubkey}`);
} else if (signer.type !== SignerType.Local) {
throw new YargsError(`pubkey ${pubkey} is not a local signer`);
} else {
selectedSigners.push({pubkey, signer});
}
}

return selectedSigners;
} else {
return signersWithPubkey.filter((signer): signer is SignerLocalPubkey => signer.signer.type === SignerType.Local);
return signersWithPubkey;
}
}

// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
async function resolveValidatorIndexes(client: Api, signersToExit: SignerLocalPubkey[]) {
async function resolveValidatorIndexes(client: Api, signersToExit: SignerPubkey[]) {
const pubkeys = signersToExit.map(({pubkey}) => pubkey);

const res = await client.beacon.getStateValidators("head", {id: pubkeys});
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/e2e/importFromFsDirect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import fs from "node:fs";
import path from "node:path";
import {rimraf} from "rimraf";
import {getMochaContext} from "@lodestar/test-utils/mocha";
import {getKeystoresStr} from "@lodestar/test-utils";
import {testFilesDir} from "../utils.js";
import {cachedPubkeysHex, cachedSeckeysHex} from "../utils/cachedKeys.js";
import {expectKeys, startValidatorWithKeyManager} from "../utils/validator.js";
import {getKeystoresStr} from "../utils/keystores.js";

describe("import from fs same cmd as validate", function () {
const testContext = getMochaContext(this);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/e2e/importFromFsPreStep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import {rimraf} from "rimraf";
import {expect} from "chai";
import {getMochaContext} from "@lodestar/test-utils/mocha";
import {execCliCommand} from "@lodestar/test-utils";
import {getKeystoresStr} from "@lodestar/test-utils";
import {testFilesDir} from "../utils.js";
import {cachedPubkeysHex, cachedSeckeysHex} from "../utils/cachedKeys.js";
import {expectKeys, startValidatorWithKeyManager} from "../utils/validator.js";
import {getKeystoresStr} from "../utils/keystores.js";

describe("import from fs then validate", function () {
const testContext = getMochaContext(this);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/e2e/importKeystoresFromApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import {Interchange} from "@lodestar/validator";
import {ApiError, HttpStatusCode} from "@lodestar/api";
import {bufferStderr, spawnCliCommand} from "@lodestar/test-utils";
import {getMochaContext} from "@lodestar/test-utils/mocha";
import {getKeystoresStr} from "@lodestar/test-utils";
import {testFilesDir} from "../utils.js";
import {cachedPubkeysHex, cachedSeckeysHex} from "../utils/cachedKeys.js";
import {expectDeepEquals} from "../utils/runUtils.js";
import {expectKeys, startValidatorWithKeyManager} from "../utils/validator.js";
import {getKeystoresStr} from "../utils/keystores.js";

describe("import keystores from api", function () {
const testContext = getMochaContext(this);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import {rimraf} from "rimraf";
import {Interchange} from "@lodestar/validator";
import {ApiError} from "@lodestar/api";
import {getMochaContext} from "@lodestar/test-utils/mocha";
import {getKeystoresStr} from "@lodestar/test-utils";
import {testFilesDir} from "../utils.js";
import {cachedPubkeysHex, cachedSeckeysHex} from "../utils/cachedKeys.js";
import {expectDeepEquals} from "../utils/runUtils.js";
import {startValidatorWithKeyManager} from "../utils/validator.js";
import {getKeystoresStr} from "../utils/keystores.js";

describe("import keystores from api, test DefaultProposerConfig", function () {
this.timeout("30s");
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/test/e2e/voluntaryExitRemoteSigner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import path from "node:path";
import {retry} from "@lodestar/utils";
import {ApiError, getClient} from "@lodestar/api";
import {config} from "@lodestar/config/default";
import {interopSecretKey, interopSecretKeys} from "@lodestar/state-transition";
import {spawnCliCommand, execCliCommand, startExternalSigner, ExternalSignerTests} from "@lodestar/test-utils";
import {getMochaContext} from "@lodestar/test-utils/mocha";
import {getKeystoresStr} from "@lodestar/test-utils";
import {testFilesDir} from "../utils.js";

describe("voluntaryExit using remote signer", function () {
this.timeout("30s");

let externalSigner: ExternalSignerTests;

before("start external signer container", async () => {
const password = "password";
externalSigner = await startExternalSigner({
keystoreStrings: await getKeystoresStr(
password,
interopSecretKeys(2).map((k) => k.toHex())
),
password: password,
});
});

after("stop external signer container", async () => {
await externalSigner.container.stop();
});

it("Perform a voluntary exit", async () => {
const testContext = getMochaContext(this);

const restPort = 9596;
const devBnProc = await spawnCliCommand(
"packages/cli/bin/lodestar.js",
[
// ⏎
"dev",
`--dataDir=${path.join(testFilesDir, "dev-voluntary-exit")}`,
"--genesisValidators=8",
"--startValidators=0..7",
"--rest",
`--rest.port=${restPort}`,
// 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",
],
{pipeStdioToParent: false, logPrefix: "dev", testContext}
);

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

const baseUrl = `http://127.0.0.1:${restPort}`;
// To cleanup the event stream connection
const httpClientController = new AbortController();
const client = getClient({baseUrl, getAbortSignal: () => httpClientController.signal}, {config});

// Wait for beacon node API to be available + genesis
await retry(
async () => {
const head = await client.beacon.getBlockHeader("head");
ApiError.assert(head);
if (head.response.data.header.message.slot < 1) throw Error("pre-genesis");
},
{retryDelay: 1000, retries: 20}
);

const indexesToExit = [0, 1];
const pubkeysToExit = indexesToExit.map((i) => interopSecretKey(i).toPublicKey().toHex());

await execCliCommand(
"packages/cli/bin/lodestar.js",
[
"validator",
"voluntary-exit",
"--network=dev",
"--yes",
`--externalSigner.url=${externalSigner.url}`,
"--externalSigner.fetch=true",
`--server=${baseUrl}`,
`--pubkeys=${pubkeysToExit.join(",")}`,
],
{pipeStdioToParent: false, logPrefix: "voluntary-exit"}
);

for (const pubkey of pubkeysToExit) {
await retry(
async () => {
const res = await client.beacon.getStateValidator("head", pubkey);
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 ${pubkey} = ${res.response.data.status}`);
}
},
{retryDelay: 1000, retries: 20}
);
}

// Disconnect the event stream for the client
httpClientController.abort();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import fs from "node:fs";
import path from "node:path";
import {rimraf} from "rimraf";
import {expect} from "chai";
import {getKeystoresStr} from "@lodestar/test-utils";
import {cachedSeckeysHex} from "../../utils/cachedKeys.js";
import {getKeystoresStr} from "../../utils/keystores.js";
import {testFilesDir} from "../../utils.js";
import {decryptKeystoreDefinitions} from "../../../src/cmds/validator/keymanager/decryptKeystoreDefinitions.js";
import {LocalKeystoreDefinition} from "../../../src/cmds/validator/keymanager/interface.js";
Expand Down
6 changes: 5 additions & 1 deletion packages/test-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,15 @@
"blockchain"
],
"dependencies": {
"@chainsafe/bls": "^7.1.2",
"@chainsafe/bls-keystore": "^3.0.0",
"@lodestar/utils": "^1.12.0",
"axios": "^1.3.4",
"chai": "^4.3.7",
"mocha": "^10.2.0",
"sinon": "^15.0.3"
"sinon": "^15.0.3",
"testcontainers": "^10.3.2",
"tmp": "^0.2.1"
},
"devDependencies": {
"@types/mocha": "^10.0.1",
Expand Down
Loading