diff --git a/data/serializer/src/main/java/tech/pegasys/teku/provider/BLSPubKeyKeyDeserializer.java b/data/serializer/src/main/java/tech/pegasys/teku/provider/BLSPubKeyKeyDeserializer.java new file mode 100644 index 00000000000..673d5d12482 --- /dev/null +++ b/data/serializer/src/main/java/tech/pegasys/teku/provider/BLSPubKeyKeyDeserializer.java @@ -0,0 +1,26 @@ +/* + * Copyright 2022 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.teku.provider; + +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.KeyDeserializer; +import tech.pegasys.teku.api.schema.BLSPubKey; + +public class BLSPubKeyKeyDeserializer extends KeyDeserializer { + + @Override + public Object deserializeKey(String key, DeserializationContext ctxt) { + return BLSPubKey.fromHexString(key); + } +} diff --git a/data/serializer/src/main/java/tech/pegasys/teku/provider/JsonProvider.java b/data/serializer/src/main/java/tech/pegasys/teku/provider/JsonProvider.java index a2c16832f2c..8d2b0cb61d9 100644 --- a/data/serializer/src/main/java/tech/pegasys/teku/provider/JsonProvider.java +++ b/data/serializer/src/main/java/tech/pegasys/teku/provider/JsonProvider.java @@ -44,6 +44,7 @@ private void addTekuMappers() { module.addDeserializer(BLSPubKey.class, new BLSPubKeyDeserializer()); module.addDeserializer(BLSSignature.class, new BLSSignatureDeserializer()); module.addSerializer(BLSSignature.class, new BLSSignatureSerializer()); + module.addKeyDeserializer(BLSPubKey.class, new BLSPubKeyKeyDeserializer()); module.addDeserializer(Bytes32.class, new Bytes32Deserializer()); module.addDeserializer(Bytes4.class, new Bytes4Deserializer()); diff --git a/teku/src/main/java/tech/pegasys/teku/cli/options/ValidatorProposerOptions.java b/teku/src/main/java/tech/pegasys/teku/cli/options/ValidatorProposerOptions.java index 614d79d170d..00d55dfeee2 100644 --- a/teku/src/main/java/tech/pegasys/teku/cli/options/ValidatorProposerOptions.java +++ b/teku/src/main/java/tech/pegasys/teku/cli/options/ValidatorProposerOptions.java @@ -15,6 +15,7 @@ import picocli.CommandLine.Option; import tech.pegasys.teku.config.TekuConfiguration; +import tech.pegasys.teku.validator.api.ValidatorConfig; public class ValidatorProposerOptions { @Option( @@ -26,7 +27,31 @@ public class ValidatorProposerOptions { hidden = true) private String proposerDefaultFeeRecipient = null; + @Option( + names = {"--Xvalidators-proposer-config"}, + paramLabel = "", + description = "remote URL or local file path to load proposer configuration from", + arity = "0..1", + hidden = true) + private String proposerConfig = null; + + @Option( + names = {"--Xvalidators-proposer-config-refresh-rate"}, + paramLabel = "", + description = + "Sets the frequency, in seconds, at which the proposer configuration is reloaded. " + + "0 means never refresh.", + arity = "0..1", + hidden = true) + private long proposerConfigRefreshRate = + ValidatorConfig.DEFAULT_VALIDATOR_PROPOSER_CONFIG_REFRESH_RATE.toSeconds(); + public void configure(TekuConfiguration.Builder builder) { - builder.validator(config -> config.proposerDefaultFeeRecipient(proposerDefaultFeeRecipient)); + builder.validator( + config -> + config + .proposerDefaultFeeRecipient(proposerDefaultFeeRecipient) + .proposerConfigSource(proposerConfig) + .proposerConfigSourceRefreshRate(proposerConfigRefreshRate)); } } diff --git a/validator/api/src/main/java/tech/pegasys/teku/validator/api/ValidatorConfig.java b/validator/api/src/main/java/tech/pegasys/teku/validator/api/ValidatorConfig.java index c6f2b5c9de6..a107b3071ab 100644 --- a/validator/api/src/main/java/tech/pegasys/teku/validator/api/ValidatorConfig.java +++ b/validator/api/src/main/java/tech/pegasys/teku/validator/api/ValidatorConfig.java @@ -40,6 +40,8 @@ public class ValidatorConfig { public static final boolean DEFAULT_GENERATE_EARLY_ATTESTATIONS = true; public static final boolean DEFAULT_SEND_ATTESTATIONS_AS_BATCH = true; public static final Optional DEFAULT_GRAFFITI = Optional.empty(); + public static final Duration DEFAULT_VALIDATOR_PROPOSER_CONFIG_REFRESH_RATE = + Duration.ofSeconds(60); private final List validatorKeys; private final List validatorExternalSignerPublicKeySources; @@ -58,6 +60,8 @@ public class ValidatorConfig { private final boolean useDependentRoots; private final boolean generateEarlyAttestations; private final Optional proposerDefaultFeeRecipient; + private final String proposerConfigSource; + private final long proposerConfigSourceRefreshRate; private ValidatorConfig( final List validatorKeys, @@ -76,7 +80,9 @@ private ValidatorConfig( final int validatorExternalSignerConcurrentRequestLimit, final boolean useDependentRoots, final boolean generateEarlyAttestations, - final Optional proposerDefaultFeeRecipient) { + final Optional proposerDefaultFeeRecipient, + final String proposerConfigSource, + final long proposerConfigSourceRefreshRate) { this.validatorKeys = validatorKeys; this.validatorExternalSignerPublicKeySources = validatorExternalSignerPublicKeySources; this.validatorExternalSignerUrl = validatorExternalSignerUrl; @@ -97,6 +103,8 @@ private ValidatorConfig( this.useDependentRoots = useDependentRoots; this.generateEarlyAttestations = generateEarlyAttestations; this.proposerDefaultFeeRecipient = proposerDefaultFeeRecipient; + this.proposerConfigSource = proposerConfigSource; + this.proposerConfigSourceRefreshRate = proposerConfigSourceRefreshRate; } public static Builder builder() { @@ -195,6 +203,8 @@ public static final class Builder { private boolean useDependentRoots = DEFAULT_USE_DEPENDENT_ROOTS; private boolean generateEarlyAttestations = DEFAULT_GENERATE_EARLY_ATTESTATIONS; private Optional proposerDefaultFeeRecipient = Optional.empty(); + private String proposerConfigSource; + private long proposerConfigSourceRefreshRate; private Builder() {} @@ -304,6 +314,16 @@ public Builder proposerDefaultFeeRecipient(final String proposerDefaultFeeRecipi return this; } + public Builder proposerConfigSource(final String proposerConfigSource) { + this.proposerConfigSource = proposerConfigSource; + return this; + } + + public Builder proposerConfigSourceRefreshRate(final long proposerConfigSourceRefreshRate) { + this.proposerConfigSourceRefreshRate = proposerConfigSourceRefreshRate; + return this; + } + public ValidatorConfig build() { validateExternalSignerUrlAndPublicKeys(); validateExternalSignerKeystoreAndPasswordFileConfig(); @@ -326,7 +346,9 @@ public ValidatorConfig build() { validatorExternalSignerConcurrentRequestLimit, useDependentRoots, generateEarlyAttestations, - proposerDefaultFeeRecipient); + proposerDefaultFeeRecipient, + proposerConfigSource, + proposerConfigSourceRefreshRate); } private void validateExternalSignerUrlAndPublicKeys() { diff --git a/validator/client/src/main/java/tech/pegasys/teku/validator/client/ProposerConfig.java b/validator/client/src/main/java/tech/pegasys/teku/validator/client/ProposerConfig.java new file mode 100644 index 00000000000..7a02cf4521e --- /dev/null +++ b/validator/client/src/main/java/tech/pegasys/teku/validator/client/ProposerConfig.java @@ -0,0 +1,51 @@ +/* + * Copyright 2022 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.teku.validator.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; +import java.util.Optional; +import tech.pegasys.teku.api.schema.BLSPubKey; +import tech.pegasys.teku.infrastructure.ssz.type.Bytes20; + +public class ProposerConfig { + @JsonProperty(value = "proposer_config", required = true) + private Map proposerConfig; + + @JsonProperty(value = "default_config", required = true) + private Config defaultConfig; + + public Optional getConfigForPubKey(final String pubKey) { + return getConfigForPubKey(BLSPubKey.fromHexString(pubKey)); + } + + public Optional getConfigForPubKey(final BLSPubKey pubKey) { + return Optional.ofNullable(proposerConfig.get(pubKey)); + } + + public Optional getDefaultConfig() { + return Optional.ofNullable(defaultConfig); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Config { + @JsonProperty(value = "fee_recipient", required = true) + private Bytes20 feeRecipient; + + public Bytes20 getFeeRecipient() { + return feeRecipient; + } + } +} diff --git a/validator/client/src/main/java/tech/pegasys/teku/validator/client/ProposerConfigService.java b/validator/client/src/main/java/tech/pegasys/teku/validator/client/ProposerConfigService.java new file mode 100644 index 00000000000..c6abed978cb --- /dev/null +++ b/validator/client/src/main/java/tech/pegasys/teku/validator/client/ProposerConfigService.java @@ -0,0 +1,63 @@ +/* + * Copyright 2022 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.teku.validator.client; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import tech.pegasys.teku.infrastructure.async.AsyncRunner; +import tech.pegasys.teku.infrastructure.async.Cancellable; +import tech.pegasys.teku.infrastructure.async.SafeFuture; +import tech.pegasys.teku.service.serviceutils.Service; + +public class ProposerConfigService extends Service { + private static final Logger LOG = LogManager.getLogger(); + static final Duration DEFAULT_REFRESH_RATE = Duration.ofMinutes(1); + + private final Duration refreshRate; + private final AsyncRunner asyncRunner; + private Optional cancellable = Optional.empty(); + private final AtomicBoolean running = new AtomicBoolean(false); + + public ProposerConfigService(final AsyncRunner asyncRunner) { + this.asyncRunner = asyncRunner; + this.refreshRate = DEFAULT_REFRESH_RATE; + } + + @Override + protected SafeFuture doStart() { + cancellable = + Optional.of( + asyncRunner.runWithFixedDelay( + this::loadProposerConfig, + refreshRate, + error -> LOG.error("Failed to refresh proposer configuration", error))); + // Run immediately on start + loadProposerConfig(); + return SafeFuture.COMPLETE; + } + + @Override + protected SafeFuture doStop() { + cancellable.ifPresent(Cancellable::cancel); + cancellable = Optional.empty(); + return SafeFuture.COMPLETE; + } + + private void loadProposerConfig() { + if (running.compareAndSet(false, true)) {} + } +} diff --git a/validator/client/src/main/java/tech/pegasys/teku/validator/client/loader/ProposerConfigLoader.java b/validator/client/src/main/java/tech/pegasys/teku/validator/client/loader/ProposerConfigLoader.java new file mode 100644 index 00000000000..b16485bf8a1 --- /dev/null +++ b/validator/client/src/main/java/tech/pegasys/teku/validator/client/loader/ProposerConfigLoader.java @@ -0,0 +1,53 @@ +/* + * Copyright 2022 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.teku.validator.client.loader; + +import java.io.IOException; +import java.net.URL; +import java.util.Optional; +import tech.pegasys.teku.infrastructure.exceptions.InvalidConfigurationException; +import tech.pegasys.teku.provider.JsonProvider; +import tech.pegasys.teku.validator.client.ProposerConfig; + +public class ProposerConfigLoader { + private final JsonProvider jsonProvider = new JsonProvider(); + + public ProposerConfig getProposerConfig(final String source) { + try { + final ProposerConfig proposerConfig = + jsonProvider.getObjectMapper().readValue(source, ProposerConfig.class); + return proposerConfig; + } catch (IOException ex) { + throw new InvalidConfigurationException("Failed to proposer config from URL " + source, ex); + } + } + + public ProposerConfig getProposerConfig(final URL source) { + try { + final ProposerConfig proposerConfig = + jsonProvider.getObjectMapper().readValue(source, ProposerConfig.class); + return proposerConfig; + } catch (IOException ex) { + throw new InvalidConfigurationException("Failed to proposer config from URL " + source, ex); + } + } + + private Optional getUrl(final String source) { + try { + return Optional.of(new URL(source)); + } catch (IOException e) { + return Optional.empty(); + } + } +} diff --git a/validator/client/src/test/java/tech/pegasys/teku/validator/client/loader/ProposerConfigLoaderTest.java b/validator/client/src/test/java/tech/pegasys/teku/validator/client/loader/ProposerConfigLoaderTest.java new file mode 100644 index 00000000000..8969684285f --- /dev/null +++ b/validator/client/src/test/java/tech/pegasys/teku/validator/client/loader/ProposerConfigLoaderTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2022 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.teku.validator.client.loader; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.io.Resources; +import java.net.URL; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import tech.pegasys.teku.infrastructure.ssz.type.Bytes20; +import tech.pegasys.teku.validator.client.ProposerConfig; +import tech.pegasys.teku.validator.client.ProposerConfig.Config; + +public class ProposerConfigLoaderTest { + private final ProposerConfigLoader loader = new ProposerConfigLoader(); + + @Test + void shouldLoadValidConfig() { + final URL resource = Resources.getResource("proposerConfig.json"); + + ProposerConfig config = loader.getProposerConfig(resource); + Optional theConfig = + config.getConfigForPubKey( + "0xa057816155ad77931185101128655c0191bd0214c201ca48ed887f6c4c6adf334070efcd75140eada5ac83a92506dd7a"); + assertThat(theConfig).isPresent(); + assertThat(theConfig.get().getFeeRecipient()) + .isEqualTo(Bytes20.fromHexString("0x50155530FCE8a85ec7055A5F8b2bE214B3DaeFd3")); + + Optional defaultConfig = config.getDefaultConfig(); + assertThat(defaultConfig).isPresent(); + assertThat(defaultConfig.get().getFeeRecipient()) + .isEqualTo(Bytes20.fromHexString("0x6e35733c5af9B61374A128e6F85f553aF09ff89A")); + } +} diff --git a/validator/client/src/test/resources/proposerConfig.json b/validator/client/src/test/resources/proposerConfig.json new file mode 100644 index 00000000000..28caad57bd3 --- /dev/null +++ b/validator/client/src/test/resources/proposerConfig.json @@ -0,0 +1,10 @@ +{ + "proposer_config": { + "0xa057816155ad77931185101128655c0191bd0214c201ca48ed887f6c4c6adf334070efcd75140eada5ac83a92506dd7a": { + "fee_recipient": "0x50155530FCE8a85ec7055A5F8b2bE214B3DaeFd3" + } + }, + "default_config": { + "fee_recipient": "0x6e35733c5af9B61374A128e6F85f553aF09ff89A" + } +} \ No newline at end of file