From 9e3e25806a5ee0f271253055b5b84a66ea7898c0 Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Thu, 1 Aug 2024 01:39:44 +1000 Subject: [PATCH] feat: Expose set finalized/safe block in plugin-api BlockchainService (#7382) * feat: Expose set finalized and safe block in plugin-api BlockchainService * check for poa network before setting finalized block * changelog * Add BlockchainService set finalized acceptance test --------- Signed-off-by: Usman Saleem Signed-off-by: gconnect --- CHANGELOG.md | 1 + acceptance-tests/test-plugins/build.gradle | 1 + .../TestBlockchainServiceFinalizedPlugin.java | 149 +++++++++++++++++ ...kchainServiceFinalizedBlockPluginTest.java | 158 ++++++++++++++++++ .../besu/services/BlockchainServiceImpl.java | 34 +++- plugin-api/build.gradle | 2 +- .../plugin/services/BlockchainService.java | 20 +++ 7 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 acceptance-tests/test-plugins/src/main/java/org/hyperledger/besu/tests/acceptance/plugins/TestBlockchainServiceFinalizedPlugin.java create mode 100644 acceptance-tests/tests/src/test/java/org/hyperledger/besu/tests/acceptance/plugins/BlockchainServiceFinalizedBlockPluginTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 498b3c1edd6..c5d782ba257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Remove long-deprecated `perm*whitelist*` methods [#7401](https://github.com/hyperledger/besu/pull/7401) ### Additions and Improvements +- Expose set finalized/safe block in plugin api BlockchainService. These method can be used by plugins to set finalized/safe block for a PoA network (such as QBFT, IBFT and Clique).[#7382](https://github.com/hyperledger/besu/pull/7382) ### Bug fixes diff --git a/acceptance-tests/test-plugins/build.gradle b/acceptance-tests/test-plugins/build.gradle index c31175cee8f..19a45d218ad 100644 --- a/acceptance-tests/test-plugins/build.gradle +++ b/acceptance-tests/test-plugins/build.gradle @@ -7,6 +7,7 @@ dependencies { implementation project(':datatypes') implementation project(':ethereum:core') implementation project(':ethereum:rlp') + implementation project(':ethereum:api') implementation project(':plugin-api') implementation 'com.google.auto.service:auto-service' implementation 'info.picocli:picocli' diff --git a/acceptance-tests/test-plugins/src/main/java/org/hyperledger/besu/tests/acceptance/plugins/TestBlockchainServiceFinalizedPlugin.java b/acceptance-tests/test-plugins/src/main/java/org/hyperledger/besu/tests/acceptance/plugins/TestBlockchainServiceFinalizedPlugin.java new file mode 100644 index 00000000000..a61b80ab8e4 --- /dev/null +++ b/acceptance-tests/test-plugins/src/main/java/org/hyperledger/besu/tests/acceptance/plugins/TestBlockchainServiceFinalizedPlugin.java @@ -0,0 +1,149 @@ +/* + * Copyright contributors to Hyperledger Besu. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.tests.acceptance.plugins; + +import org.hyperledger.besu.datatypes.Hash; +import org.hyperledger.besu.ethereum.api.jsonrpc.internal.parameters.JsonRpcParameter; +import org.hyperledger.besu.ethereum.api.jsonrpc.internal.response.RpcErrorType; +import org.hyperledger.besu.plugin.BesuContext; +import org.hyperledger.besu.plugin.BesuPlugin; +import org.hyperledger.besu.plugin.data.BlockContext; +import org.hyperledger.besu.plugin.services.BlockchainService; +import org.hyperledger.besu.plugin.services.RpcEndpointService; +import org.hyperledger.besu.plugin.services.exception.PluginRpcEndpointException; +import org.hyperledger.besu.plugin.services.rpc.PluginRpcRequest; + +import java.util.Optional; + +import com.google.auto.service.AutoService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@AutoService(BesuPlugin.class) +public class TestBlockchainServiceFinalizedPlugin implements BesuPlugin { + private static final Logger LOG = + LoggerFactory.getLogger(TestBlockchainServiceFinalizedPlugin.class); + private static final String RPC_NAMESPACE = "updater"; + private static final String RPC_METHOD_FINALIZED_BLOCK = "updateFinalizedBlockV1"; + private static final String RPC_METHOD_SAFE_BLOCK = "updateSafeBlockV1"; + + @Override + public void register(final BesuContext besuContext) { + LOG.trace("Registering plugin ..."); + + final RpcEndpointService rpcEndpointService = + besuContext + .getService(RpcEndpointService.class) + .orElseThrow( + () -> + new RuntimeException( + "Failed to obtain RpcEndpointService from the BesuContext.")); + + final BlockchainService blockchainService = + besuContext + .getService(BlockchainService.class) + .orElseThrow( + () -> + new RuntimeException( + "Failed to obtain BlockchainService from the BesuContext.")); + + final FinalizationUpdaterRpcMethod rpcMethod = + new FinalizationUpdaterRpcMethod(blockchainService); + rpcEndpointService.registerRPCEndpoint( + RPC_NAMESPACE, RPC_METHOD_FINALIZED_BLOCK, rpcMethod::setFinalizedBlock); + rpcEndpointService.registerRPCEndpoint( + RPC_NAMESPACE, RPC_METHOD_SAFE_BLOCK, rpcMethod::setSafeBlock); + } + + @Override + public void start() { + LOG.trace("Starting plugin ..."); + } + + @Override + public void stop() { + LOG.trace("Stopping plugin ..."); + } + + static class FinalizationUpdaterRpcMethod { + private final BlockchainService blockchainService; + private final JsonRpcParameter parameterParser = new JsonRpcParameter(); + + FinalizationUpdaterRpcMethod(final BlockchainService blockchainService) { + this.blockchainService = blockchainService; + } + + Boolean setFinalizedBlock(final PluginRpcRequest request) { + return setFinalizedOrSafeBlock(request, true); + } + + Boolean setSafeBlock(final PluginRpcRequest request) { + return setFinalizedOrSafeBlock(request, false); + } + + private Boolean setFinalizedOrSafeBlock( + final PluginRpcRequest request, final boolean isFinalized) { + final Long blockNumberToSet = parseResult(request); + + // lookup finalized block by number in local chain + final Optional finalizedBlock = + blockchainService.getBlockByNumber(blockNumberToSet); + if (finalizedBlock.isEmpty()) { + throw new PluginRpcEndpointException( + RpcErrorType.BLOCK_NOT_FOUND, + "Block not found in the local chain: " + blockNumberToSet); + } + + try { + final Hash blockHash = finalizedBlock.get().getBlockHeader().getBlockHash(); + if (isFinalized) { + blockchainService.setFinalizedBlock(blockHash); + } else { + blockchainService.setSafeBlock(blockHash); + } + } catch (final IllegalArgumentException e) { + throw new PluginRpcEndpointException( + RpcErrorType.BLOCK_NOT_FOUND, + "Block not found in the local chain: " + blockNumberToSet); + } catch (final UnsupportedOperationException e) { + throw new PluginRpcEndpointException( + RpcErrorType.METHOD_NOT_ENABLED, + "Method not enabled for PoS network: setFinalizedBlock"); + } catch (final Exception e) { + throw new PluginRpcEndpointException( + RpcErrorType.INTERNAL_ERROR, "Error setting finalized block: " + blockNumberToSet); + } + + return Boolean.TRUE; + } + + private Long parseResult(final PluginRpcRequest request) { + Long blockNumber; + try { + final Object[] params = request.getParams(); + blockNumber = parameterParser.required(params, 0, Long.class); + } catch (final Exception e) { + throw new PluginRpcEndpointException(RpcErrorType.INVALID_PARAMS, e.getMessage()); + } + + if (blockNumber <= 0) { + throw new PluginRpcEndpointException( + RpcErrorType.INVALID_PARAMS, "Block number must be greater than 0"); + } + + return blockNumber; + } + } +} diff --git a/acceptance-tests/tests/src/test/java/org/hyperledger/besu/tests/acceptance/plugins/BlockchainServiceFinalizedBlockPluginTest.java b/acceptance-tests/tests/src/test/java/org/hyperledger/besu/tests/acceptance/plugins/BlockchainServiceFinalizedBlockPluginTest.java new file mode 100644 index 00000000000..dd620844b13 --- /dev/null +++ b/acceptance-tests/tests/src/test/java/org/hyperledger/besu/tests/acceptance/plugins/BlockchainServiceFinalizedBlockPluginTest.java @@ -0,0 +1,158 @@ +/* + * Copyright contributors to Hyperledger Besu. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.tests.acceptance.plugins; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.hyperledger.besu.config.JsonUtil; +import org.hyperledger.besu.tests.acceptance.dsl.AcceptanceTestBase; +import org.hyperledger.besu.tests.acceptance.dsl.node.BesuNode; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class BlockchainServiceFinalizedBlockPluginTest extends AcceptanceTestBase { + + private BesuNode pluginNode; + private BesuNode minerNode; + private OkHttpClient client; + protected static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + + @BeforeEach + public void setUp() throws Exception { + minerNode = besu.createMinerNode("minerNode"); + pluginNode = + besu.createPluginsNode("node1", List.of("testPlugins"), List.of("--rpc-http-api=UPDATER")); + cluster.start(minerNode, pluginNode); + client = new OkHttpClient(); + } + + @Test + @DisplayName("Calling update{Finalized/Safe}BlockV1 will set block") + public void canUpdateFinalizedBlock() throws IOException { + pluginNode.verify(blockchain.minimumHeight(5)); + + // RPC Call. Set the safe block number to 3 + final ObjectNode resultJson = callTestMethod("updater_updateSafeBlockV1", List.of(3L)); + assertThat(resultJson.get("result").asBoolean()).isTrue(); + + // RPC Call. Set the finalized block number to 4 + final ObjectNode finalizedResultJson = + callTestMethod("updater_updateFinalizedBlockV1", List.of(4L)); + assertThat(finalizedResultJson.get("result").asBoolean()).isTrue(); + + final ObjectNode blockNumberSafeResult = + callTestMethod("eth_getBlockByNumber", List.of("SAFE", true)); + assertThat(blockNumberSafeResult.get("result").get("number").asText()).isEqualTo("0x3"); + + // Verify the value was set + final ObjectNode blockNumberFinalizedResult = + callTestMethod("eth_getBlockByNumber", List.of("FINALIZED", true)); + assertThat(blockNumberFinalizedResult.get("result").get("number").asText()).isEqualTo("0x4"); + } + + @Test + @DisplayName("Calling update{Finalized/Safe}BlockV1 with non-existing block number returns error") + public void nonExistingBlockNumberReturnsError() throws IOException { + pluginNode.verify(blockchain.minimumHeight(5)); + + final ObjectNode[] resultsJson = new ObjectNode[2]; + resultsJson[0] = callTestMethod("updater_updateFinalizedBlockV1", List.of(250L)); + resultsJson[1] = callTestMethod("updater_updateSafeBlockV1", List.of(250L)); + + for (int i = 0; i < resultsJson.length; i++) { + assertThat(resultsJson[i].get("error").get("code").asInt()).isEqualTo(-32000); + assertThat(resultsJson[i].get("error").get("message").asText()).isEqualTo("Block not found"); + assertThat(resultsJson[i].get("error").get("data").asText()) + .isEqualTo("Block not found in the local chain: 250"); + } + } + + @ParameterizedTest(name = "{index} - blockNumber={0}") + @ValueSource(longs = {-1, 0}) + @DisplayName("Calling update{Finalized/Safe}BlockV1 with block number <= 0 returns error") + public void invalidBlockNumberReturnsError(final long blockNumber) throws IOException { + pluginNode.verify(blockchain.minimumHeight(5)); + + final ObjectNode[] resultsJson = new ObjectNode[2]; + resultsJson[0] = callTestMethod("updater_updateFinalizedBlockV1", List.of(blockNumber)); + resultsJson[1] = callTestMethod("updater_updateSafeBlockV1", List.of(blockNumber)); + + for (int i = 0; i < resultsJson.length; i++) { + assertThat(resultsJson[i].get("error").get("code").asInt()).isEqualTo(-32602); + assertThat(resultsJson[i].get("error").get("message").asText()).isEqualTo("Invalid params"); + assertThat(resultsJson[i].get("error").get("data").asText()) + .isEqualTo("Block number must be greater than 0"); + } + } + + @Test + @DisplayName("Calling update{Finalized/Safe}BlockV1 with invalid block number type returns error") + public void invalidBlockNumberTypeReturnsError() throws IOException { + pluginNode.verify(blockchain.minimumHeight(5)); + + final ObjectNode[] resultsJson = new ObjectNode[2]; + resultsJson[0] = callTestMethod("updater_updateFinalizedBlockV1", List.of("testblock")); + resultsJson[1] = callTestMethod("updater_updateSafeBlockV1", List.of("testblock")); + + for (int i = 0; i < resultsJson.length; i++) { + assertThat(resultsJson[i].get("error").get("code").asInt()).isEqualTo(-32602); + assertThat(resultsJson[i].get("error").get("message").asText()).isEqualTo("Invalid params"); + assertThat(resultsJson[i].get("error").get("data").asText()) + .isEqualTo( + "Invalid json rpc parameter at index 0. Supplied value was: 'testblock' of type: 'java.lang.String' - expected type: 'java.lang.Long'"); + } + } + + private ObjectNode callTestMethod(final String method, final List params) + throws IOException { + String format = + String.format( + "{\"jsonrpc\":\"2.0\",\"method\":\"%s\",\"params\":[%s],\"id\":42}", + method, + params.stream().map(value -> "\"" + value + "\"").collect(Collectors.joining(","))); + + RequestBody body = RequestBody.create(format, JSON); + + final String resultString = + client + .newCall( + new Request.Builder() + .post(body) + .url( + "http://" + + pluginNode.getHostName() + + ":" + + pluginNode.getJsonRpcPort().get() + + "/") + .build()) + .execute() + .body() + .string(); + return JsonUtil.objectNodeFromString(resultString); + } +} diff --git a/besu/src/main/java/org/hyperledger/besu/services/BlockchainServiceImpl.java b/besu/src/main/java/org/hyperledger/besu/services/BlockchainServiceImpl.java index d9e5dbb9ef7..1f014ee0610 100644 --- a/besu/src/main/java/org/hyperledger/besu/services/BlockchainServiceImpl.java +++ b/besu/src/main/java/org/hyperledger/besu/services/BlockchainServiceImpl.java @@ -20,6 +20,7 @@ import org.hyperledger.besu.ethereum.chain.MutableBlockchain; import org.hyperledger.besu.ethereum.core.Block; import org.hyperledger.besu.ethereum.mainnet.ProtocolSchedule; +import org.hyperledger.besu.ethereum.mainnet.ProtocolSpec; import org.hyperledger.besu.ethereum.mainnet.feemarket.BaseFeeMarket; import org.hyperledger.besu.ethereum.mainnet.feemarket.FeeMarket; import org.hyperledger.besu.plugin.Unstable; @@ -46,7 +47,7 @@ public class BlockchainServiceImpl implements BlockchainService { public BlockchainServiceImpl() {} /** - * Instantiates a new Blockchain service. + * Initialize the Blockchain service. * * @param protocolContext the protocol context * @param protocolSchedule the protocol schedule @@ -135,6 +136,37 @@ public Optional getFinalizedBlock() { return blockchain.getFinalized(); } + @Override + public void setFinalizedBlock(final Hash blockHash) { + final var protocolSpec = getProtocolSpec(blockHash); + + if (protocolSpec.isPoS()) { + throw new UnsupportedOperationException( + "Marking block as finalized is not supported for PoS networks"); + } + blockchain.setFinalized(blockHash); + } + + @Override + public void setSafeBlock(final Hash blockHash) { + final var protocolSpec = getProtocolSpec(blockHash); + + if (protocolSpec.isPoS()) { + throw new UnsupportedOperationException( + "Marking block as safe is not supported for PoS networks"); + } + + blockchain.setSafeBlock(blockHash); + } + + private ProtocolSpec getProtocolSpec(final Hash blockHash) { + return blockchain + .getBlockByHash(blockHash) + .map(Block::getHeader) + .map(protocolSchedule::getByBlockHeader) + .orElseThrow(() -> new IllegalArgumentException("Block not found: " + blockHash)); + } + private static BlockContext blockContext( final Supplier blockHeaderSupplier, final Supplier blockBodySupplier) { diff --git a/plugin-api/build.gradle b/plugin-api/build.gradle index c7203e2f883..cc4b3237b52 100644 --- a/plugin-api/build.gradle +++ b/plugin-api/build.gradle @@ -70,7 +70,7 @@ Calculated : ${currentHash} tasks.register('checkAPIChanges', FileStateChecker) { description = "Checks that the API for the Plugin-API project does not change without deliberate thought" files = sourceSets.main.allJava.files - knownHash = 'o0IuPVpCvE3YUzuZgVf4NP74q1ECpkbAkeC6u/Nr8yU=' + knownHash = 'tXFd8EcMJtD+ZSLJxWJLYRZD0d3njRz+3Ubey2zFM2A=' } check.dependsOn('checkAPIChanges') diff --git a/plugin-api/src/main/java/org/hyperledger/besu/plugin/services/BlockchainService.java b/plugin-api/src/main/java/org/hyperledger/besu/plugin/services/BlockchainService.java index 69a2e6a8220..84a573aadc3 100644 --- a/plugin-api/src/main/java/org/hyperledger/besu/plugin/services/BlockchainService.java +++ b/plugin-api/src/main/java/org/hyperledger/besu/plugin/services/BlockchainService.java @@ -87,4 +87,24 @@ public interface BlockchainService extends BesuService { * @return the block hash of the finalized block */ Optional getFinalizedBlock(); + + /** + * Set the finalized block for non-PoS networks + * + * @param blockHash Hash of the finalized block + * @throws IllegalArgumentException if the block hash is not on the chain + * @throws UnsupportedOperationException if the network is a PoS network + */ + void setFinalizedBlock(Hash blockHash) + throws IllegalArgumentException, UnsupportedOperationException; + + /** + * Set the safe block for non-PoS networks + * + * @param blockHash Hash of the finalized block + * @throws IllegalArgumentException if the block hash is not on the chain + * @throws UnsupportedOperationException if the network is a PoS network + */ + void setSafeBlock(Hash blockHash) throws IllegalArgumentException, UnsupportedOperationException; + ; }