Acceptance test for execution engine apis (#3533)

* added acceptance test for execution engine apis

* removed check of payload id length

Signed-off-by: Daniel Lehrner <daniel.lehrner@consensys.net>

Co-authored-by: Sally MacFarlane <sally.macfarlane@consensys.net>
pull/3556/head
Daniel Lehrner 3 years ago committed by GitHub
parent 64ec6c763e
commit 09c99c8f1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 47
      acceptance-tests/dsl/src/main/java/org/hyperledger/besu/tests/acceptance/dsl/engine/EngineTestCase.java
  2. 27
      acceptance-tests/dsl/src/main/java/org/hyperledger/besu/tests/acceptance/dsl/node/BesuNode.java
  3. 9
      acceptance-tests/dsl/src/main/java/org/hyperledger/besu/tests/acceptance/dsl/node/ProcessBesuNodeRunner.java
  4. 8
      acceptance-tests/dsl/src/main/java/org/hyperledger/besu/tests/acceptance/dsl/node/configuration/BesuNodeConfigurationBuilder.java
  5. 16
      acceptance-tests/dsl/src/main/java/org/hyperledger/besu/tests/acceptance/dsl/node/configuration/BesuNodeFactory.java
  6. 2
      acceptance-tests/dsl/src/main/java/org/hyperledger/besu/tests/acceptance/dsl/node/configuration/NodeConfiguration.java
  7. 1
      acceptance-tests/dsl/src/main/java/org/hyperledger/besu/tests/acceptance/dsl/privacy/PrivacyNode.java
  8. 109
      acceptance-tests/tests/src/test/java/org/hyperledger/besu/tests/acceptance/jsonrpc/ExecutionEngineAcceptanceTest.java
  9. 35
      acceptance-tests/tests/src/test/resources/jsonrpc/engine/genesis.json
  10. 32
      acceptance-tests/tests/src/test/resources/jsonrpc/engine/test-cases/01_prepare_payload.json
  11. 31
      acceptance-tests/tests/src/test/resources/jsonrpc/engine/test-cases/02_get_payload.json
  12. 35
      acceptance-tests/tests/src/test/resources/jsonrpc/engine/test-cases/03_execute_payload.json
  13. 28
      acceptance-tests/tests/src/test/resources/jsonrpc/engine/test-cases/04_update_forkchoice.json
  14. 19
      acceptance-tests/tests/src/test/resources/jsonrpc/engine/test-cases/05_unknown_payload.json
  15. 32
      acceptance-tests/tests/src/test/resources/jsonrpc/engine/test-cases/06_invalid_head.json
  16. 5
      besu/src/main/java/org/hyperledger/besu/Runner.java
  17. 2
      ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/jsonrpc/internal/results/EngineGetPayloadResult.java
  18. 3
      ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/jsonrpc/internal/results/EngineUpdateForkchoiceResult.java

@ -0,0 +1,47 @@
/*
* Copyright Hyperledger Besu Contributors.
*
* 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.dsl.engine;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
public class EngineTestCase {
private final JsonNode request;
private final JsonNode response;
private final int statusCode;
@JsonCreator
public EngineTestCase(
@JsonProperty("request") final JsonNode request,
@JsonProperty("response") final JsonNode response,
@JsonProperty("statusCode") final int statusCode) {
this.request = request;
this.response = response;
this.statusCode = statusCode;
}
public JsonNode getRequest() {
return request;
}
public JsonNode getResponse() {
return response;
}
public int getStatusCode() {
return statusCode;
}
}

@ -97,6 +97,7 @@ public class BesuNode implements NodeConfiguration, RunnableNode, AutoCloseable
private final List<String> runCommand;
private PrivacyParameters privacyParameters = PrivacyParameters.DEFAULT;
private final JsonRpcConfiguration jsonRpcConfiguration;
private final Optional<JsonRpcConfiguration> engineRpcConfiguration;
private final WebSocketConfiguration webSocketConfiguration;
private final MetricsConfiguration metricsConfiguration;
private Optional<PermissioningConfiguration> permissioningConfiguration;
@ -126,6 +127,7 @@ public class BesuNode implements NodeConfiguration, RunnableNode, AutoCloseable
final Optional<Path> dataPath,
final MiningParameters miningParameters,
final JsonRpcConfiguration jsonRpcConfiguration,
final Optional<JsonRpcConfiguration> engineRpcConfiguration,
final WebSocketConfiguration webSocketConfiguration,
final MetricsConfiguration metricsConfiguration,
final Optional<PermissioningConfiguration> permissioningConfiguration,
@ -171,6 +173,7 @@ public class BesuNode implements NodeConfiguration, RunnableNode, AutoCloseable
this.name = name;
this.miningParameters = miningParameters;
this.jsonRpcConfiguration = jsonRpcConfiguration;
this.engineRpcConfiguration = engineRpcConfiguration;
this.webSocketConfiguration = webSocketConfiguration;
this.metricsConfiguration = metricsConfiguration;
this.permissioningConfiguration = permissioningConfiguration;
@ -219,6 +222,11 @@ public class BesuNode implements NodeConfiguration, RunnableNode, AutoCloseable
return jsonRpcConfiguration().isEnabled();
}
@Override
public boolean isEngineRpcEnabled() {
return engineRpcConfiguration.isPresent() && engineRpcConfiguration.get().isEnabled();
}
private boolean isWebSocketsRpcEnabled() {
return webSocketConfiguration().isEnabled();
}
@ -282,6 +290,15 @@ public class BesuNode implements NodeConfiguration, RunnableNode, AutoCloseable
}
}
public Optional<String> engineHttpUrl() {
if (isJsonRpcEnabled()) {
return Optional.of(
"http://" + jsonRpcConfiguration.getHost() + ":" + getEngineJsonRpcPort().get());
} else {
return Optional.empty();
}
}
private Optional<String> wsRpcBaseUrl() {
if (isWebSocketsRpcEnabled()) {
return Optional.of(
@ -336,7 +353,7 @@ public class BesuNode implements NodeConfiguration, RunnableNode, AutoCloseable
@Override
public Optional<Integer> getEngineJsonRpcPort() {
if (isJsonRpcEnabled()) {
if (isEngineRpcEnabled()) {
return Optional.of(Integer.valueOf(portsProperties.getProperty("engine-json-rpc")));
} else {
return Optional.empty();
@ -560,6 +577,14 @@ public class BesuNode implements NodeConfiguration, RunnableNode, AutoCloseable
}
}
Optional<Integer> jsonEngineListenPort() {
if (isEngineRpcEnabled()) {
return Optional.of(engineRpcConfiguration.get().getPort());
} else {
return Optional.empty();
}
}
boolean wsRpcEnabled() {
return isWebSocketsRpcEnabled();
}

@ -187,9 +187,14 @@ public class ProcessBesuNodeRunner implements BesuNodeRunner {
params.add("--rpc-http-authentication-jwt-algorithm");
params.add(node.jsonRpcConfiguration().getAuthenticationAlgorithm().toString());
}
// TODO: properly handle engine rpc, set port to 0 to make tests pass
}
if (node.isEngineRpcEnabled()) {
params.add("--Xmerge-support");
params.add("true");
params.add("--engine-rpc-http-port");
params.add("0");
params.add(node.jsonEngineListenPort().get().toString());
}
if (node.wsRpcEnabled()) {

@ -151,6 +151,14 @@ public class BesuNodeConfigurationBuilder {
return this;
}
public BesuNodeConfigurationBuilder engineRpcEnabled() {
this.engineRpcConfiguration.setEnabled(true);
this.engineRpcConfiguration.setPort(0);
this.engineRpcConfiguration.setHostsAllowlist(singletonList("*"));
return this;
}
public BesuNodeConfigurationBuilder metricsEnabled() {
this.metricsConfiguration =
MetricsConfiguration.builder()

@ -62,6 +62,7 @@ public class BesuNodeFactory {
config.getDataPath(),
config.getMiningParameters(),
config.getJsonRpcConfiguration(),
config.getEngineRpcConfiguration(),
config.getWebSocketConfiguration(),
config.getMetricsConfiguration(),
config.getPermissioningConfiguration(),
@ -502,6 +503,21 @@ public class BesuNodeFactory {
return create(builder.build());
}
public BesuNode createExecutionEngineGenesisNode(final String name, final String genesisPath)
throws IOException {
final String genesisFile = GenesisConfigurationFactory.readGenesisFile(genesisPath);
return create(
new BesuNodeConfigurationBuilder()
.name(name)
.genesisConfigProvider((a) -> Optional.of(genesisFile))
.devMode(false)
.bootnodeEligible(false)
.miningEnabled()
.jsonRpcEnabled()
.engineRpcEnabled()
.build());
}
public BesuNode createCliqueNodeWithValidators(final String name, final String... validators)
throws IOException {

@ -42,6 +42,8 @@ public interface NodeConfiguration {
boolean isJsonRpcEnabled();
boolean isEngineRpcEnabled();
GenesisConfigurationProvider getGenesisConfigProvider();
Optional<String> getGenesisConfig();

@ -101,6 +101,7 @@ public class PrivacyNode implements AutoCloseable {
besuConfig.getDataPath(),
besuConfig.getMiningParameters(),
besuConfig.getJsonRpcConfiguration(),
besuConfig.getEngineRpcConfiguration(),
besuConfig.getWebSocketConfiguration(),
besuConfig.getMetricsConfiguration(),
besuConfig.getPermissioningConfiguration(),

@ -0,0 +1,109 @@
/*
* Copyright Hyperledger Besu Contributors.
*
* 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.jsonrpc;
import static org.assertj.core.api.Assertions.assertThat;
import org.hyperledger.besu.tests.acceptance.dsl.condition.net.NetConditions;
import org.hyperledger.besu.tests.acceptance.dsl.engine.EngineTestCase;
import org.hyperledger.besu.tests.acceptance.dsl.node.BesuNode;
import org.hyperledger.besu.tests.acceptance.dsl.node.cluster.Cluster;
import org.hyperledger.besu.tests.acceptance.dsl.node.configuration.BesuNodeFactory;
import org.hyperledger.besu.tests.acceptance.dsl.transaction.net.NetTransactions;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.Call;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
@RunWith(Parameterized.class)
public class ExecutionEngineAcceptanceTest {
private static final String GENESIS_FILE = "/jsonrpc/engine/genesis.json";
private static final String TEST_CASE_PATH = "/jsonrpc/engine/test-cases/";
private static final MediaType MEDIA_TYPE_JSON =
MediaType.parse("application/json; charset=utf-8");
private static Cluster cluster;
private static BesuNode executionEngine;
private static OkHttpClient consensusClient;
private static ObjectMapper mapper;
private final URI testCaseFileURI;
public ExecutionEngineAcceptanceTest(final String ignored, final URI testCaseFileURI) {
this.testCaseFileURI = testCaseFileURI;
}
@BeforeClass
public static void init() throws IOException {
cluster = new Cluster(new NetConditions(new NetTransactions()));
executionEngine =
new BesuNodeFactory().createExecutionEngineGenesisNode("executionEngine", GENESIS_FILE);
cluster.start(executionEngine);
consensusClient = new OkHttpClient();
mapper = new ObjectMapper();
}
@Test
public void test() throws IOException {
final EngineTestCase testCase = mapper.readValue(testCaseFileURI.toURL(), EngineTestCase.class);
final Call preparePayloadRequest =
consensusClient.newCall(
new Request.Builder()
.url(executionEngine.engineHttpUrl().get())
.post(RequestBody.create(testCase.getRequest().toString(), MEDIA_TYPE_JSON))
.build());
final Response response = preparePayloadRequest.execute();
assertThat(response.code()).isEqualTo(testCase.getStatusCode());
assertThat(response.body().string()).isEqualTo(testCase.getResponse().toPrettyString());
}
@Parameterized.Parameters(name = "{0}")
public static Iterable<Object[]> testCases() throws URISyntaxException {
final File testCasePath =
new File(ExecutionEngineAcceptanceTest.class.getResource(TEST_CASE_PATH).toURI());
final File[] testCasesList = testCasePath.listFiles();
return Arrays.stream(testCasesList)
.sorted()
.map(file -> new Object[] {file.getName(), file.toURI()})
.collect(Collectors.toList());
}
@AfterClass
public static void tearDown() {
cluster.close();
}
}

@ -0,0 +1,35 @@
{
"config": {
"chainId":1,
"homesteadBlock":0,
"eip150Block":0,
"eip155Block":0,
"eip158Block":0,
"byzantiumBlock":0,
"constantinopleBlock":0,
"petersburgBlock":0,
"istanbulBlock":0,
"muirGlacierBlock":0,
"berlinBlock":0,
"londonBlock":0,
"clique": {
"period": 5,
"epoch": 30000
},
"terminalTotalDifficulty":0
},
"nonce":"0x42",
"timestamp":"0x0",
"extraData":"0x0000000000000000000000000000000000000000000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"gasLimit":"0x1C9C380",
"difficulty":"0x400000000",
"mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000",
"coinbase":"0x0000000000000000000000000000000000000000",
"alloc":{
"0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b":{"balance":"0x6d6172697573766477000000"}
},
"number":"0x0",
"gasUsed":"0x0",
"parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000",
"baseFeePerGas":"0x7"
}

@ -0,0 +1,32 @@
{
"request": {
"jsonrpc": "2.0",
"method": "engine_forkchoiceUpdatedV1",
"params": [
{
"headBlockHash": "0x3b8fb240d288781d4aac94d3fd16809ee413bc99294a085798a589dae51ddd4a",
"safeBlockHash": "0x3b8fb240d288781d4aac94d3fd16809ee413bc99294a085798a589dae51ddd4a",
"finalizedBlockHash": "0x0000000000000000000000000000000000000000000000000000000000000000"
},
{
"timestamp": "0x5",
"prevRandao": "0x0000000000000000000000000000000000000000000000000000000000000000",
"suggestedFeeRecipient": "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b"
}
],
"id": 67
},
"response": {
"jsonrpc": "2.0",
"id": 67,
"result": {
"payloadStatus": {
"status": "VALID",
"latestValidHash": "0x3b8fb240d288781d4aac94d3fd16809ee413bc99294a085798a589dae51ddd4a",
"validationError": null
},
"payloadId": "0x0000000021f32cc1"
}
},
"statusCode" : 200
}

@ -0,0 +1,31 @@
{
"request": {
"jsonrpc": "2.0",
"method": "engine_getPayloadV1",
"params": [
"0x0000000021f32cc1"
],
"id": 67
},
"response": {
"jsonrpc": "2.0",
"id": 67,
"result": {
"parentHash": "0x3b8fb240d288781d4aac94d3fd16809ee413bc99294a085798a589dae51ddd4a",
"feeRecipient": "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b",
"stateRoot": "0xca3149fa9e37db08d1cd49c9061db1002ef1cd58db2210f2115c8c989b2bdf45",
"receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",
"logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"prevRandao": "0x0000000000000000000000000000000000000000000000000000000000000000",
"blockNumber": "0x1",
"gasLimit": "0x1c9c380",
"gasUsed": "0x0",
"timestamp": "0x5",
"extraData": "0x",
"baseFeePerGas": "0x7",
"blockHash": "0x3559e851470f6e7bbed1db474980683e8c315bfce99b2a6ef47c057c04de7858",
"transactions": []
}
},
"statusCode": 200
}

@ -0,0 +1,35 @@
{
"request": {
"jsonrpc": "2.0",
"method": "engine_newPayloadV1",
"params": [
{
"parentHash": "0x3b8fb240d288781d4aac94d3fd16809ee413bc99294a085798a589dae51ddd4a",
"feeRecipient": "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b",
"stateRoot": "0xca3149fa9e37db08d1cd49c9061db1002ef1cd58db2210f2115c8c989b2bdf45",
"receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",
"logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"prevRandao": "0x0000000000000000000000000000000000000000000000000000000000000000",
"blockNumber": "0x1",
"gasLimit": "0x1c9c380",
"gasUsed": "0x0",
"timestamp": "0x5",
"extraData": "0x",
"baseFeePerGas": "0x7",
"blockHash": "0x3559e851470f6e7bbed1db474980683e8c315bfce99b2a6ef47c057c04de7858",
"transactions": []
}
],
"id": 67
},
"response": {
"jsonrpc": "2.0",
"id": 67,
"result": {
"status": "VALID",
"latestValidHash": "0x3559e851470f6e7bbed1db474980683e8c315bfce99b2a6ef47c057c04de7858",
"validationError": null
}
},
"statusCode": 200
}

@ -0,0 +1,28 @@
{
"request": {
"jsonrpc": "2.0",
"method": "engine_forkchoiceUpdatedV1",
"params": [
{
"headBlockHash": "0x3559e851470f6e7bbed1db474980683e8c315bfce99b2a6ef47c057c04de7858",
"safeBlockHash": "0x3559e851470f6e7bbed1db474980683e8c315bfce99b2a6ef47c057c04de7858",
"finalizedBlockHash": "0x3b8fb240d288781d4aac94d3fd16809ee413bc99294a085798a589dae51ddd4a"
},
null
],
"id": 67
},
"response": {
"jsonrpc": "2.0",
"id": 67,
"result": {
"payloadStatus": {
"status": "VALID",
"latestValidHash": "0x3559e851470f6e7bbed1db474980683e8c315bfce99b2a6ef47c057c04de7858",
"validationError": null
},
"payloadId": null
}
},
"statusCode": 200
}

@ -0,0 +1,19 @@
{
"request": {
"jsonrpc": "2.0",
"method": "engine_getPayloadV1",
"params": [
"0x0000000000000000"
],
"id": 67
},
"response": {
"jsonrpc": "2.0",
"id": 67,
"error": {
"code": -32001,
"message": "Payload does not exist / is not available"
}
},
"statusCode": 200
}

@ -0,0 +1,32 @@
{
"request": {
"jsonrpc": "2.0",
"method": "engine_forkchoiceUpdatedV1",
"params": [
{
"headBlockHash": "0x0000000000000000000000000000000000000000000000000000000000000001",
"safeBlockHash": "0x3b8fb240d288781d4aac94d3fd16809ee413bc99294a085798a589dae51ddd4a",
"finalizedBlockHash": "0x0000000000000000000000000000000000000000000000000000000000000000"
},
{
"timestamp": "0x5",
"prevRandao": "0x0000000000000000000000000000000000000000000000000000000000000000",
"suggestedFeeRecipient": "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b"
}
],
"id": 67
},
"response": {
"jsonrpc": "2.0",
"id": 67,
"result": {
"payloadStatus": {
"status": "SYNCING",
"latestValidHash": null,
"validationError": null
},
"payloadId": null
}
},
"statusCode": 200
}

@ -140,6 +140,7 @@ public class Runner implements AutoCloseable {
transactionPoolEvictionService.start();
LOG.info("Ethereum main loop is up.");
// we write these values to disk to be able to access them during the acceptance tests
writeBesuPortsToFile();
writeBesuNetworksToFile();
writePidFile();
@ -268,6 +269,10 @@ public class Runner implements AutoCloseable {
if (port.isPresent()) {
properties.setProperty("metrics", String.valueOf(port.get()));
}
port = getEngineJsonRpcPort();
if (port.isPresent()) {
properties.setProperty("engine-json-rpc", String.valueOf(port.get()));
}
// create besu.ports file
createBesuFile(
properties, "ports", "This file contains the ports used by the running instance of Besu");

@ -24,7 +24,6 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import org.apache.tuweni.bytes.Bytes32;
@JsonPropertyOrder({
"blockHash",
"parentHash",
"feeRecipient",
"stateRoot",
@ -37,6 +36,7 @@ import org.apache.tuweni.bytes.Bytes32;
"timestamp",
"extraData",
"baseFeePerGas",
"blockHash",
"transactions"
})
public class EngineGetPayloadResult {

@ -14,7 +14,6 @@
*/
package org.hyperledger.besu.ethereum.api.jsonrpc.internal.results;
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
import static org.hyperledger.besu.ethereum.api.jsonrpc.internal.methods.ExecutionEngineJsonRpcMethod.EngineStatus.INVALID;
import static org.hyperledger.besu.ethereum.api.jsonrpc.internal.methods.ExecutionEngineJsonRpcMethod.EngineStatus.INVALID_TERMINAL_BLOCK;
import static org.hyperledger.besu.ethereum.api.jsonrpc.internal.methods.ExecutionEngineJsonRpcMethod.EngineStatus.SYNCING;
@ -28,7 +27,6 @@ import java.util.EnumSet;
import java.util.Optional;
import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
@JsonPropertyOrder({"payloadStatus", "payloadId"})
@ -56,7 +54,6 @@ public class EngineUpdateForkchoiceResult {
}
@JsonGetter(value = "payloadId")
@JsonInclude(NON_NULL)
public String getPayloadId() {
return Optional.ofNullable(payloadId).map(PayloadIdentifier::toHexString).orElse(null);
}

Loading…
Cancel
Save