Skip to content

Commit 489168b

Browse files
authored
Allow voluntary exit command to write to file rather than publish (Consensys#8168)
* Allow voluntary exit command to write to file rather than publish The advantage of this is that the message can avoid epoch validation, and also it can separate the submission of the exit from creation, so if something fails, it can have further investigation. The format saved to json will allow it to be posted directly to the voluntary exits beacon-api POST endpoint. Assists with Consensys#8158 but only as a potential workaround, allowing us to avoid setting up the keymanager-api to generate an exit. Signed-off-by: Paul Harris <[email protected]>
1 parent 8325000 commit 489168b

File tree

3 files changed

+181
-16
lines changed

3 files changed

+181
-16
lines changed

teku/src/integration-test/java/tech/pegasys/teku/cli/subcommand/VoluntaryExitCommandTest.java

+72
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,23 @@
3030
import java.io.PrintStream;
3131
import java.io.PrintWriter;
3232
import java.io.StringWriter;
33+
import java.nio.file.Files;
34+
import java.nio.file.Path;
3335
import java.util.ArrayList;
3436
import java.util.Arrays;
3537
import java.util.Collections;
3638
import java.util.List;
3739
import java.util.function.Supplier;
40+
import org.apache.commons.lang3.StringUtils;
3841
import org.apache.tuweni.bytes.Bytes;
3942
import org.apache.tuweni.bytes.Bytes32;
4043
import org.junit.jupiter.api.AfterEach;
4144
import org.junit.jupiter.api.BeforeEach;
4245
import org.junit.jupiter.api.Test;
46+
import org.junit.jupiter.api.condition.DisabledOnOs;
47+
import org.junit.jupiter.api.condition.OS;
4348
import org.junit.jupiter.api.extension.ExtendWith;
49+
import org.junit.jupiter.api.io.TempDir;
4450
import org.mockserver.integration.ClientAndServer;
4551
import org.mockserver.junit.jupiter.MockServerExtension;
4652
import org.mockserver.model.Parameter;
@@ -281,6 +287,72 @@ void shouldNotWarn_NotWithdrawableIfCapellaEnabled() throws JsonProcessingExcept
281287
validatorPubKey1, validatorPubKey2, keyManagerPubKey1, keyManagerPubKey2, nonExistingKey);
282288
}
283289

290+
@Test
291+
void shouldGenerateExitWithoutSendingToNode(@TempDir final Path tempDir) throws IOException {
292+
configureSuccessfulSpecResponse(mockBeaconServer, TestSpecFactory.createMinimalCapella());
293+
final Path outputFolder = tempDir.resolve("out");
294+
final List<String> args = new ArrayList<>();
295+
args.addAll(commandArgs);
296+
args.addAll(
297+
List.of(
298+
"--validator-public-keys",
299+
validatorPubKey1,
300+
"--save-exits-path",
301+
outputFolder.toAbsolutePath().toString()));
302+
int parseResult = beaconNodeCommand.parse(args.toArray(new String[0]));
303+
assertThat(parseResult).isEqualTo(0);
304+
assertThat(stdErr.toString(UTF_8)).isEmpty();
305+
306+
final String outString = stdOut.toString(UTF_8);
307+
assertThat(outString).contains("Saving exits to folder " + outputFolder);
308+
// specifically we wrote validator 1's exit message
309+
assertThat(outString).contains("Writing signed exit for a756543");
310+
// generically, we only wrote 1 signed exit to file
311+
assertThat(StringUtils.countMatches(outString, "Writing signed exit for")).isEqualTo(1);
312+
// we succeeded in writing a file
313+
assertThat(outputFolder.resolve("a756543_exit.json").toFile().exists()).isTrue();
314+
assertValidatorsNotExited(validatorPubKey1);
315+
}
316+
317+
@Test
318+
void shouldFailIfSaveFolderCannotBeCreated(@TempDir final Path tempDir) throws IOException {
319+
configureSuccessfulSpecResponse(mockBeaconServer, TestSpecFactory.createMinimalCapella());
320+
final Path invalidOutputDestination = tempDir.resolve("testFile");
321+
Files.writeString(invalidOutputDestination, "test");
322+
final List<String> args = new ArrayList<>();
323+
args.addAll(commandArgs);
324+
args.addAll(
325+
List.of(
326+
"--validator-public-keys",
327+
validatorPubKey1,
328+
"--save-exits-path",
329+
invalidOutputDestination.toAbsolutePath().toString()));
330+
int parseResult = beaconNodeCommand.parse(args.toArray(new String[0]));
331+
assertThat(parseResult).isEqualTo(1);
332+
assertThat(stdErr.toString(UTF_8))
333+
.contains("exists and is not a directory, cannot export to this path.");
334+
}
335+
336+
@Test
337+
@DisabledOnOs(OS.WINDOWS) // can't set permissions on windows
338+
void shouldFailIfSaveFolderHasInsufficientAccess(@TempDir final Path tempDir) throws IOException {
339+
configureSuccessfulSpecResponse(mockBeaconServer, TestSpecFactory.createMinimalCapella());
340+
final Path invalidOutputDestination = tempDir.resolve("testFile");
341+
tempDir.toFile().mkdir();
342+
tempDir.toFile().setWritable(false);
343+
final List<String> args = new ArrayList<>();
344+
args.addAll(commandArgs);
345+
args.addAll(
346+
List.of(
347+
"--validator-public-keys",
348+
validatorPubKey1,
349+
"--save-exits-path",
350+
invalidOutputDestination.toAbsolutePath().toString()));
351+
int parseResult = beaconNodeCommand.parse(args.toArray(new String[0]));
352+
assertThat(parseResult).isEqualTo(1);
353+
assertThat(stdErr.toString(UTF_8)).contains("Failed to store exit for a756543");
354+
}
355+
284356
@Test
285357
void shouldExitFailureWithNoValidatorKeysFound() throws JsonProcessingException {
286358
configureSuccessfulSpecResponse(mockBeaconServer);

teku/src/main/java/tech/pegasys/teku/cli/subcommand/VoluntaryExitCommand.java

+108-15
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,24 @@
1515

1616
import static tech.pegasys.teku.cli.subcommand.RemoteSpecLoader.getSpec;
1717

18+
import com.fasterxml.jackson.core.JsonProcessingException;
1819
import it.unimi.dsi.fastutil.objects.Object2IntMap;
1920
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
21+
import java.io.File;
22+
import java.io.IOException;
2023
import java.net.ConnectException;
2124
import java.net.URI;
2225
import java.net.http.HttpClient;
2326
import java.nio.charset.Charset;
27+
import java.nio.file.Files;
2428
import java.nio.file.Path;
2529
import java.util.HashMap;
2630
import java.util.List;
2731
import java.util.Map;
2832
import java.util.Optional;
2933
import java.util.Scanner;
3034
import java.util.concurrent.Callable;
35+
import java.util.concurrent.atomic.AtomicInteger;
3136
import java.util.function.Supplier;
3237
import java.util.stream.Collectors;
3338
import org.apache.tuweni.bytes.Bytes32;
@@ -44,12 +49,14 @@
4449
import tech.pegasys.teku.cli.options.ValidatorClientDataOptions;
4550
import tech.pegasys.teku.cli.options.ValidatorClientOptions;
4651
import tech.pegasys.teku.cli.options.ValidatorKeysOptions;
52+
import tech.pegasys.teku.cli.subcommand.debug.PrettyPrintCommand;
4753
import tech.pegasys.teku.config.TekuConfiguration;
4854
import tech.pegasys.teku.infrastructure.async.AsyncRunner;
4955
import tech.pegasys.teku.infrastructure.async.AsyncRunnerFactory;
5056
import tech.pegasys.teku.infrastructure.async.MetricTrackingExecutorFactory;
5157
import tech.pegasys.teku.infrastructure.exceptions.ExceptionUtil;
5258
import tech.pegasys.teku.infrastructure.exceptions.InvalidConfigurationException;
59+
import tech.pegasys.teku.infrastructure.json.JsonUtil;
5360
import tech.pegasys.teku.infrastructure.logging.SubCommandLogger;
5461
import tech.pegasys.teku.infrastructure.logging.ValidatorLogger;
5562
import tech.pegasys.teku.infrastructure.unsigned.UInt64;
@@ -82,6 +89,7 @@
8289
footerHeading = "%n",
8390
footer = "Teku is licensed under the Apache License 2.0")
8491
public class VoluntaryExitCommand implements Callable<Integer> {
92+
8593
public static final SubCommandLogger SUB_COMMAND_LOG = new SubCommandLogger();
8694
private static final int MAX_PUBLIC_KEY_BATCH_SIZE = 50;
8795
private OkHttpValidatorRestApiClient apiClient;
@@ -150,19 +158,38 @@ public class VoluntaryExitCommand implements Callable<Integer> {
150158
arity = "0..1")
151159
private boolean includeKeyManagerKeys = false;
152160

161+
@CommandLine.Option(
162+
names = {"--save-exits-path"},
163+
description =
164+
"Save the generated exit messages to the specified path, don't validate exit epoch, and skip publishing them.",
165+
paramLabel = "<FOLDER>",
166+
arity = "1")
167+
public File voluntaryExitsFolder;
168+
153169
private AsyncRunnerFactory asyncRunnerFactory;
154170

155171
@Override
156172
public Integer call() {
157173
SUB_COMMAND_LOG.display("Loading configuration...");
158174
try {
159175
initialise();
160-
if (confirmationEnabled) {
161-
if (!confirmExits()) {
176+
177+
if (voluntaryExitsFolder != null) {
178+
SUB_COMMAND_LOG.display(
179+
"Saving exits to folder "
180+
+ voluntaryExitsFolder
181+
+ ", and not submitting to beacon-node.");
182+
if (!saveExitsToFolder()) {
162183
return 1;
163184
}
185+
} else {
186+
if (confirmationEnabled) {
187+
if (!confirmExits()) {
188+
return 1;
189+
}
190+
}
191+
getValidatorIndices(validatorsMap).forEach(this::submitExitForValidator);
164192
}
165-
getValidatorIndices(validatorsMap).forEach(this::submitExitForValidator);
166193
} catch (Exception ex) {
167194
if (ExceptionUtil.hasCause(ex, ConnectException.class)) {
168195
SUB_COMMAND_LOG.error(getFailedToConnectMessage());
@@ -180,6 +207,30 @@ public Integer call() {
180207
return 0;
181208
}
182209

210+
private boolean saveExitsToFolder() {
211+
if (voluntaryExitsFolder.exists() && !voluntaryExitsFolder.isDirectory()) {
212+
SUB_COMMAND_LOG.error(
213+
String.format(
214+
"%s exists and is not a directory, cannot export to this path.",
215+
voluntaryExitsFolder));
216+
return false;
217+
} else if (!voluntaryExitsFolder.exists()) {
218+
voluntaryExitsFolder.mkdirs();
219+
}
220+
final AtomicInteger failures = new AtomicInteger();
221+
getValidatorIndices(validatorsMap)
222+
.forEach(
223+
(publicKey, validatorIndex) -> {
224+
if (!storeExitForValidator(publicKey, validatorIndex)) {
225+
failures.incrementAndGet();
226+
}
227+
});
228+
if (failures.get() > 0) {
229+
return false;
230+
}
231+
return true;
232+
}
233+
183234
private boolean confirmExits() {
184235
SUB_COMMAND_LOG.display("Exits are going to be generated for validators: ");
185236
SUB_COMMAND_LOG.display(getValidatorAbbreviatedKeys());
@@ -207,7 +258,7 @@ private String getValidatorAbbreviatedKeys() {
207258
}
208259

209260
private Object2IntMap<BLSPublicKey> getValidatorIndices(
210-
Map<BLSPublicKey, Validator> validatorsMap) {
261+
final Map<BLSPublicKey, Validator> validatorsMap) {
211262
final Object2IntMap<BLSPublicKey> validatorIndices = new Object2IntOpenHashMap<>();
212263
final List<String> publicKeys =
213264
validatorsMap.keySet().stream().map(BLSPublicKey::toString).toList();
@@ -237,16 +288,10 @@ private Object2IntMap<BLSPublicKey> getValidatorIndices(
237288

238289
private void submitExitForValidator(final BLSPublicKey publicKey, final int validatorIndex) {
239290
try {
240-
final ForkInfo forkInfo = new ForkInfo(fork, genesisRoot);
241-
final VoluntaryExit message = new VoluntaryExit(epoch, UInt64.valueOf(validatorIndex));
242-
final BLSSignature signature =
243-
Optional.ofNullable(validatorsMap.get(publicKey))
244-
.orElseThrow()
245-
.getSigner()
246-
.signVoluntaryExit(message, forkInfo)
247-
.join();
248-
Optional<PostDataFailureResponse> response =
249-
apiClient.sendVoluntaryExit(new SignedVoluntaryExit(message, signature));
291+
final tech.pegasys.teku.spec.datastructures.operations.SignedVoluntaryExit exit =
292+
generateSignedExit(publicKey, validatorIndex);
293+
final Optional<PostDataFailureResponse> response =
294+
apiClient.sendVoluntaryExit(new SignedVoluntaryExit(exit));
250295
if (response.isPresent()) {
251296
SUB_COMMAND_LOG.error(response.get().message);
252297
} else {
@@ -261,6 +306,52 @@ private void submitExitForValidator(final BLSPublicKey publicKey, final int vali
261306
}
262307
}
263308

309+
private tech.pegasys.teku.spec.datastructures.operations.SignedVoluntaryExit generateSignedExit(
310+
final BLSPublicKey publicKey, final int validatorIndex) {
311+
final ForkInfo forkInfo = new ForkInfo(fork, genesisRoot);
312+
final VoluntaryExit message = new VoluntaryExit(epoch, UInt64.valueOf(validatorIndex));
313+
final BLSSignature signature =
314+
Optional.ofNullable(validatorsMap.get(publicKey))
315+
.orElseThrow()
316+
.getSigner()
317+
.signVoluntaryExit(message, forkInfo)
318+
.join();
319+
return new tech.pegasys.teku.spec.datastructures.operations.SignedVoluntaryExit(
320+
message, signature);
321+
}
322+
323+
private boolean storeExitForValidator(
324+
final BLSPublicKey blsPublicKey, final Integer validatorIndex) {
325+
final tech.pegasys.teku.spec.datastructures.operations.SignedVoluntaryExit exit =
326+
generateSignedExit(blsPublicKey, validatorIndex);
327+
try {
328+
SUB_COMMAND_LOG.display("Writing signed exit for " + blsPublicKey.toAbbreviatedString());
329+
Files.writeString(
330+
voluntaryExitsFolder.toPath().resolve(blsPublicKey.toAbbreviatedString() + "_exit.json"),
331+
prettyExitMessage(exit));
332+
return true;
333+
} catch (IOException e) {
334+
SUB_COMMAND_LOG.error("Failed to store exit for " + blsPublicKey.toAbbreviatedString());
335+
return false;
336+
}
337+
}
338+
339+
private String prettyExitMessage(
340+
final tech.pegasys.teku.spec.datastructures.operations.SignedVoluntaryExit
341+
signedVoluntaryExit)
342+
throws JsonProcessingException {
343+
final PrettyPrintCommand.OutputFormat json = PrettyPrintCommand.OutputFormat.JSON;
344+
return JsonUtil.serialize(
345+
json.createFactory(),
346+
gen -> {
347+
gen.useDefaultPrettyPrinter();
348+
signedVoluntaryExit
349+
.getSchema()
350+
.getJsonTypeDefinition()
351+
.serialize(signedVoluntaryExit, gen);
352+
});
353+
}
354+
264355
private Optional<UInt64> getEpoch() {
265356
return apiClient
266357
.getBlockHeader("head")
@@ -367,7 +458,9 @@ private void validateOrDefaultEpoch() {
367458
"Could not calculate epoch from latest block header, please specify --epoch");
368459
}
369460
epoch = maybeEpoch.orElseThrow();
370-
} else if (maybeEpoch.isPresent() && epoch.isGreaterThan(maybeEpoch.get())) {
461+
} else if (maybeEpoch.isPresent()
462+
&& epoch.isGreaterThan(maybeEpoch.get())
463+
&& voluntaryExitsFolder == null) {
371464
throw new InvalidConfigurationException(
372465
String.format(
373466
"The specified epoch %s is greater than current epoch %s, cannot continue.",

teku/src/main/java/tech/pegasys/teku/cli/subcommand/debug/PrettyPrintCommand.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ private InputStream openStream() throws IOException {
133133
}
134134
}
135135

136-
private enum OutputFormat {
136+
public enum OutputFormat {
137137
JSON(JsonFactory::new),
138138
YAML(YAMLFactory::new);
139139

0 commit comments

Comments
 (0)