From b4a725da0f8e62575bc0e7a0c1a1c600b787e7a9 Mon Sep 17 00:00:00 2001 From: Simon Dudley Date: Tue, 12 Sep 2023 16:28:58 +1000 Subject: [PATCH] GET /highWatermark endpoint (#908) - Add GET /highWatermark endpoint to eth2 API - Handle null epoch OR null slot (case when both are null is already handled) --- .../pegasys/web3signer/core/Eth2Runner.java | 9 +++ .../http/handlers/HighWatermarkHandler.java | 48 ++++++++++++++++ .../eth2/signing/paths/high_watermark.yaml | 24 ++++++++ .../openapi-specs/eth2/web3signer.yaml | 2 + .../DbSlashingProtection.java | 6 ++ .../SlashingProtection.java | 4 ++ .../slashingprotection/dao/MetadataDao.java | 2 +- .../dao/MetadataDaoTest.java | 57 +++++++++++++++---- 8 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 core/src/main/java/tech/pegasys/web3signer/core/service/http/handlers/HighWatermarkHandler.java create mode 100644 core/src/main/resources/openapi-specs/eth2/signing/paths/high_watermark.yaml diff --git a/core/src/main/java/tech/pegasys/web3signer/core/Eth2Runner.java b/core/src/main/java/tech/pegasys/web3signer/core/Eth2Runner.java index 295d3e517..b0c6271e6 100644 --- a/core/src/main/java/tech/pegasys/web3signer/core/Eth2Runner.java +++ b/core/src/main/java/tech/pegasys/web3signer/core/Eth2Runner.java @@ -25,6 +25,7 @@ import tech.pegasys.web3signer.core.config.BaseConfig; import tech.pegasys.web3signer.core.metrics.SlashingProtectionMetrics; import tech.pegasys.web3signer.core.service.http.SigningObjectMapperFactory; +import tech.pegasys.web3signer.core.service.http.handlers.HighWatermarkHandler; import tech.pegasys.web3signer.core.service.http.handlers.LogErrorHandler; import tech.pegasys.web3signer.core.service.http.handlers.keymanager.delete.DeleteKeystoresHandler; import tech.pegasys.web3signer.core.service.http.handlers.keymanager.imports.ImportKeystoresHandler; @@ -90,6 +91,7 @@ public class Eth2Runner extends Runner { public static final String KEYSTORES_PATH = "/eth/v1/keystores"; public static final String PUBLIC_KEYS_PATH = "/api/v1/eth2/publicKeys"; public static final String SIGN_PATH = "/api/v1/eth2/sign/:identifier"; + public static final String HIGH_WATERMARK_PATH = "/api/v1/eth2/highWatermark"; private static final Logger LOG = LogManager.getLogger(); private final Optional slashingProtectionContext; @@ -171,6 +173,13 @@ private void registerEth2Routes( addReloadHandler(router, List.of(blsSignerProvider), errorHandler); + slashingProtectionContext.ifPresent( + protectionContext -> + router + .route(HttpMethod.GET, HIGH_WATERMARK_PATH) + .handler(new HighWatermarkHandler(protectionContext.getSlashingProtection())) + .failureHandler(errorHandler)); + if (isKeyManagerApiEnabled) { router .route(HttpMethod.GET, KEYSTORES_PATH) diff --git a/core/src/main/java/tech/pegasys/web3signer/core/service/http/handlers/HighWatermarkHandler.java b/core/src/main/java/tech/pegasys/web3signer/core/service/http/handlers/HighWatermarkHandler.java new file mode 100644 index 000000000..29540dd74 --- /dev/null +++ b/core/src/main/java/tech/pegasys/web3signer/core/service/http/handlers/HighWatermarkHandler.java @@ -0,0 +1,48 @@ +/* + * Copyright 2023 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.web3signer.core.service.http.handlers; + +import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE; +import static tech.pegasys.web3signer.core.service.http.handlers.ContentTypes.JSON_UTF_8; + +import tech.pegasys.web3signer.slashingprotection.SlashingProtection; + +import java.util.Map; + +import io.vertx.core.Handler; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; + +public class HighWatermarkHandler implements Handler { + private final SlashingProtection slashingProtection; + + public HighWatermarkHandler(final SlashingProtection slashingProtection) { + this.slashingProtection = slashingProtection; + } + + @Override + public void handle(final RoutingContext context) { + JsonObject response = + slashingProtection + .getHighWatermark() + .map( + hw -> + new JsonObject( + Map.of( + "epoch", String.valueOf(hw.getEpoch()), + "slot", String.valueOf(hw.getSlot())))) + .orElse(new JsonObject()); + + context.response().putHeader(CONTENT_TYPE, JSON_UTF_8).end(response.encode()); + } +} diff --git a/core/src/main/resources/openapi-specs/eth2/signing/paths/high_watermark.yaml b/core/src/main/resources/openapi-specs/eth2/signing/paths/high_watermark.yaml new file mode 100644 index 000000000..dba0d1311 --- /dev/null +++ b/core/src/main/resources/openapi-specs/eth2/signing/paths/high_watermark.yaml @@ -0,0 +1,24 @@ +get: + tags: + - 'High Watermark' + summary: 'The High Watermark epoch and slot applicable to all validators' + description: 'Returns the uint64 epoch and slot of the high watermark. Signing of attestations or blocks are only allowed when they are lower than this high watermark. If no high watermark is set, an empty JSON object will be returned.' + operationId: 'HIGH_WATERMARK' + responses: + '200': + description: 'high watermark' + content: + application/json: + schema: + type: "object" + properties: + epoch: + type: "string" + format: "uint64" + slot: + type: "string" + format: "uint64" + '400': + description: 'Bad request format' + '500': + description: 'Internal Web3Signer server error' \ No newline at end of file diff --git a/core/src/main/resources/openapi-specs/eth2/web3signer.yaml b/core/src/main/resources/openapi-specs/eth2/web3signer.yaml index 02921d42f..2fed18419 100644 --- a/core/src/main/resources/openapi-specs/eth2/web3signer.yaml +++ b/core/src/main/resources/openapi-specs/eth2/web3signer.yaml @@ -16,6 +16,8 @@ paths: $ref: './signing/paths/sign.yaml' /api/v1/eth2/publicKeys: $ref: './signing/paths/public_keys.yaml' + /api/v1/eth2/highWatermark: + $ref: './signing/paths/high_watermark.yaml' /reload: $ref: './signing/paths/reload.yaml' /upcheck: diff --git a/slashing-protection/src/main/java/tech/pegasys/web3signer/slashingprotection/DbSlashingProtection.java b/slashing-protection/src/main/java/tech/pegasys/web3signer/slashingprotection/DbSlashingProtection.java index 2c3629063..4247a911e 100644 --- a/slashing-protection/src/main/java/tech/pegasys/web3signer/slashingprotection/DbSlashingProtection.java +++ b/slashing-protection/src/main/java/tech/pegasys/web3signer/slashingprotection/DbSlashingProtection.java @@ -16,6 +16,7 @@ import static tech.pegasys.web3signer.slashingprotection.DbLocker.lockForValidator; import tech.pegasys.web3signer.slashingprotection.DbLocker.LockType; +import tech.pegasys.web3signer.slashingprotection.dao.HighWatermark; import tech.pegasys.web3signer.slashingprotection.dao.LowWatermarkDao; import tech.pegasys.web3signer.slashingprotection.dao.MetadataDao; import tech.pegasys.web3signer.slashingprotection.dao.SignedAttestationsDao; @@ -274,6 +275,11 @@ public void updateValidatorEnabledStatus(final Bytes publicKey, final boolean en }); } + @Override + public Optional getHighWatermark() { + return jdbi.inTransaction(READ_COMMITTED, metadataDao::findHighWatermark); + } + private boolean isEnabled(final Handle handle, final int validatorId) { return validatorsDao.isEnabled(handle, validatorId); } diff --git a/slashing-protection/src/main/java/tech/pegasys/web3signer/slashingprotection/SlashingProtection.java b/slashing-protection/src/main/java/tech/pegasys/web3signer/slashingprotection/SlashingProtection.java index 12219eba8..d6e370a76 100644 --- a/slashing-protection/src/main/java/tech/pegasys/web3signer/slashingprotection/SlashingProtection.java +++ b/slashing-protection/src/main/java/tech/pegasys/web3signer/slashingprotection/SlashingProtection.java @@ -12,11 +12,13 @@ */ package tech.pegasys.web3signer.slashingprotection; +import tech.pegasys.web3signer.slashingprotection.dao.HighWatermark; import tech.pegasys.web3signer.slashingprotection.interchange.IncrementalExporter; import java.io.InputStream; import java.io.OutputStream; import java.util.List; +import java.util.Optional; import org.apache.tuweni.bytes.Bytes; import org.apache.tuweni.bytes.Bytes32; @@ -49,4 +51,6 @@ boolean maySignBlock( boolean isEnabledValidator(Bytes publicKey); void updateValidatorEnabledStatus(Bytes publicKey, boolean enabled); + + Optional getHighWatermark(); } diff --git a/slashing-protection/src/main/java/tech/pegasys/web3signer/slashingprotection/dao/MetadataDao.java b/slashing-protection/src/main/java/tech/pegasys/web3signer/slashingprotection/dao/MetadataDao.java index a5a5864fa..b52d6f26e 100644 --- a/slashing-protection/src/main/java/tech/pegasys/web3signer/slashingprotection/dao/MetadataDao.java +++ b/slashing-protection/src/main/java/tech/pegasys/web3signer/slashingprotection/dao/MetadataDao.java @@ -43,7 +43,7 @@ public Optional findHighWatermark(Handle handle) { "SELECT high_watermark_epoch as epoch, high_watermark_slot as slot FROM metadata WHERE id = ?") .bind(0, METADATA_ROW_ID) .mapToBean(HighWatermark.class) - .filter(h -> h.getEpoch() != null && h.getSlot() != null) + .filter(h -> h.getEpoch() != null || h.getSlot() != null) .findFirst(); } diff --git a/slashing-protection/src/test/java/tech/pegasys/web3signer/slashingprotection/dao/MetadataDaoTest.java b/slashing-protection/src/test/java/tech/pegasys/web3signer/slashingprotection/dao/MetadataDaoTest.java index 15e268f69..2a7e730c5 100644 --- a/slashing-protection/src/test/java/tech/pegasys/web3signer/slashingprotection/dao/MetadataDaoTest.java +++ b/slashing-protection/src/test/java/tech/pegasys/web3signer/slashingprotection/dao/MetadataDaoTest.java @@ -92,6 +92,26 @@ public void findsExistingHighWatermark(final Handle handle) { .contains(new HighWatermark(UInt64.valueOf(2), UInt64.valueOf(1))); } + @Test + public void findsExistingHighWatermarkWithOnlyEpoch(final Handle handle) { + insertGvr(handle, Bytes32.leftPad(Bytes.of(3))); + updateHighWatermark(handle, 1, null); + + final Optional existingHighWatermark = metadataDao.findHighWatermark(handle); + + assertThat(existingHighWatermark).contains(new HighWatermark(null, UInt64.valueOf(1))); + } + + @Test + public void findsExistingHighWatermarkWithOnlySlot(final Handle handle) { + insertGvr(handle, Bytes32.leftPad(Bytes.of(3))); + updateHighWatermark(handle, null, 2); + + final Optional existingHighWatermark = metadataDao.findHighWatermark(handle); + + assertThat(existingHighWatermark).contains(new HighWatermark(UInt64.valueOf(2), null)); + } + @Test public void returnsEmptyForNonExistingHighWatermark(final Handle handle) { assertThat(metadataDao.findHighWatermark(handle)).isEmpty(); @@ -110,15 +130,27 @@ public void insertsHighWatermark(final Handle handle) { int updateCount = metadataDao.updateHighWatermark(handle, highWatermark); - assertThat(updateCount).isEqualTo(1); - final List highWatermarks = - handle - .createQuery( - "SELECT high_watermark_epoch as epoch, high_watermark_slot as slot FROM metadata") - .mapToBean(HighWatermark.class) - .list(); - assertThat(highWatermarks.size()).isEqualTo(1); - assertThat(highWatermarks.get(0)).isEqualTo(highWatermark); + assertHighWatermarkUpdatedSuccessfully(handle, updateCount, highWatermark); + } + + @Test + public void insertsOnlyEpochHighWatermark(final Handle handle) { + insertGvr(handle, Bytes32.leftPad(Bytes.of(3))); + HighWatermark highWatermark = new HighWatermark(null, UInt64.valueOf(1)); + + int updateCount = metadataDao.updateHighWatermark(handle, highWatermark); + + assertHighWatermarkUpdatedSuccessfully(handle, updateCount, highWatermark); + } + + @Test + public void insertsOnlySlotHighWatermark(final Handle handle) { + insertGvr(handle, Bytes32.leftPad(Bytes.of(3))); + HighWatermark highWatermark = new HighWatermark(UInt64.valueOf(1), null); + + int updateCount = metadataDao.updateHighWatermark(handle, highWatermark); + + assertHighWatermarkUpdatedSuccessfully(handle, updateCount, highWatermark); } @Test @@ -129,6 +161,11 @@ public void updatesHighWatermark(final Handle handle) { int updateCount = metadataDao.updateHighWatermark(handle, highWatermark); + assertHighWatermarkUpdatedSuccessfully(handle, updateCount, highWatermark); + } + + private void assertHighWatermarkUpdatedSuccessfully( + Handle handle, int updateCount, HighWatermark highWatermark) { assertThat(updateCount).isEqualTo(1); final List highWatermarks = handle @@ -220,7 +257,7 @@ private void insertLowWatermarks(Handle handle) { MAX_LOW_WATERMARK_SOURCE_EPOCH); } - private void updateHighWatermark(final Handle handle, final int epoch, final int slot) { + private void updateHighWatermark(final Handle handle, final Integer epoch, final Integer slot) { handle .createUpdate("UPDATE metadata set high_watermark_epoch=:epoch, high_watermark_slot=:slot") .bind("epoch", epoch)