diff --git a/README.md b/README.md index 7e0f6770..d16e3a4c 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ View logs output and results: ```shell script ./hiveview --serve --logdir ./workspace/logs ``` -## [JSON-RPC API (20)](https://samba-portal-node.postman.co/workspace/Samba-Portal-Node-Workspace~8bf54719-5e6d-4476-8b33-6434dc57d833/request/33150235-eb63c4bf-82ff-477e-a17d-616657e9cdbc?action=share&creator=33150235&ctx=documentation&active-environment=33150235-5c222146-bd60-431b-bb15-f3f9dc8fc9cc) +## [JSON-RPC API (23)](https://samba-portal-node.postman.co/workspace/Samba-Portal-Node-Workspace~8bf54719-5e6d-4476-8b33-6434dc57d833/request/33150235-eb63c4bf-82ff-477e-a17d-616657e9cdbc?action=share&creator=33150235&ctx=documentation&active-environment=33150235-5c222146-bd60-431b-bb15-f3f9dc8fc9cc) #### History - portal_historyAddEnr @@ -140,6 +140,7 @@ View logs output and results: - portal_historyPing - portal_historyStore - portal_historyPutContent +- portal_historyRoutingTableInfo #### Discv5 - discv5_getEnr, diff --git a/core/src/main/java/samba/api/HistoryAPI.java b/core/src/main/java/samba/api/HistoryAPI.java index 6c38c6c9..21ed0e50 100644 --- a/core/src/main/java/samba/api/HistoryAPI.java +++ b/core/src/main/java/samba/api/HistoryAPI.java @@ -48,6 +48,8 @@ Optional offer( Optional recursiveFindNodes(final String nodeId); + Optional>> getRoutingTable(); + // For Besu Optional getBlockHeaderByBlockHash(Hash blockHash); diff --git a/core/src/main/java/samba/api/HistoryAPIClient.java b/core/src/main/java/samba/api/HistoryAPIClient.java index 97e8b4f5..129eff8d 100644 --- a/core/src/main/java/samba/api/HistoryAPIClient.java +++ b/core/src/main/java/samba/api/HistoryAPIClient.java @@ -93,6 +93,11 @@ public Optional recursiveFindNodes(String nodeId) { return RecursiveFindNodes.execute(this.historyNetworkInternalAPI, nodeId); } + @Override + public Optional>> getRoutingTable() { + return GetRoutingTable.execute(this.historyNetworkInternalAPI); + } + @Override public Optional getBlockHeaderByBlockHash(Hash blockHash) { return GetBlockHeaderByBlockHash.execute(this.historyNetworkInternalAPI, blockHash); diff --git a/core/src/main/java/samba/api/jsonrpc/PortalHistoryRoutingTableInfo.java b/core/src/main/java/samba/api/jsonrpc/PortalHistoryRoutingTableInfo.java new file mode 100644 index 00000000..561cb1f6 --- /dev/null +++ b/core/src/main/java/samba/api/jsonrpc/PortalHistoryRoutingTableInfo.java @@ -0,0 +1,45 @@ +package samba.api.jsonrpc; + +import samba.api.Discv5API; +import samba.api.HistoryAPI; +import samba.api.jsonrpc.results.RoutingTableInfoResult; +import samba.jsonrpc.config.RpcMethod; +import samba.jsonrpc.reponse.JsonRpcMethod; +import samba.jsonrpc.reponse.JsonRpcRequestContext; +import samba.jsonrpc.reponse.JsonRpcResponse; +import samba.jsonrpc.reponse.RpcErrorType; + +public class PortalHistoryRoutingTableInfo implements JsonRpcMethod { + + private final HistoryAPI historyAPI; + private final Discv5API discv5API; + + public PortalHistoryRoutingTableInfo(final HistoryAPI historyAPI, final Discv5API discv5API) { + this.historyAPI = historyAPI; + this.discv5API = discv5API; + } + + @Override + public String getName() { + return RpcMethod.PORTAL_HISTORY_ROUTING_TABLE_INFO.getMethodName(); + } + + @Override + public JsonRpcResponse response(JsonRpcRequestContext requestContext) { + return this.discv5API + .getNodeInfo() + .flatMap( + info -> + this.historyAPI + .getRoutingTable() + .map( + table -> { + RoutingTableInfoResult result = + new RoutingTableInfoResult(info.getNodeId(), table); + return createSuccessResponse(requestContext, result); + })) + .orElseGet( + () -> + createJsonRpcInvalidRequestResponse(requestContext, RpcErrorType.INVALID_REQUEST)); + } +} diff --git a/core/src/main/java/samba/domain/dht/NodeTable.java b/core/src/main/java/samba/domain/dht/NodeTable.java index f02f2e89..c3d286a9 100644 --- a/core/src/main/java/samba/domain/dht/NodeTable.java +++ b/core/src/main/java/samba/domain/dht/NodeTable.java @@ -3,6 +3,7 @@ import java.time.Clock; import java.util.Comparator; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Spliterator; @@ -95,4 +96,8 @@ private Optional getBucket(final int distance) { public boolean isNodeIgnored(NodeRecord nodeRecord) { return this.livenessManager.isABadPeer(nodeRecord); } + + public List> getNodeRecordBuckets() { + return this.buckets.values().stream().map(KBucket::getAllNodes).toList(); + } } diff --git a/core/src/main/java/samba/network/history/HistoryNetwork.java b/core/src/main/java/samba/network/history/HistoryNetwork.java index bd437663..51c194ce 100644 --- a/core/src/main/java/samba/network/history/HistoryNetwork.java +++ b/core/src/main/java/samba/network/history/HistoryNetwork.java @@ -816,6 +816,11 @@ public Optional traceGetContent( } } + @Override + public List> getRoutingTable() { + return this.routingTable.getNodeRecordBuckets(); + } + @Override public Optional recursiveFindNodes( final String nodeId, Set excludedNodes, final int timeout) { diff --git a/core/src/main/java/samba/network/history/api/HistoryNetworkInternalAPI.java b/core/src/main/java/samba/network/history/api/HistoryNetworkInternalAPI.java index ff165370..16c1e1d8 100644 --- a/core/src/main/java/samba/network/history/api/HistoryNetworkInternalAPI.java +++ b/core/src/main/java/samba/network/history/api/HistoryNetworkInternalAPI.java @@ -61,4 +61,6 @@ Optional recursiveFindNodes( Optional traceGetContent( ContentKey contentKey, int timeout, long startTime); + + List> getRoutingTable(); } diff --git a/core/src/main/java/samba/network/history/api/methods/GetRoutingTable.java b/core/src/main/java/samba/network/history/api/methods/GetRoutingTable.java new file mode 100644 index 00000000..0ff758df --- /dev/null +++ b/core/src/main/java/samba/network/history/api/methods/GetRoutingTable.java @@ -0,0 +1,49 @@ +package samba.network.history.api.methods; + +import samba.network.history.api.HistoryNetworkInternalAPI; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.apache.tuweni.bytes.Bytes; +import org.ethereum.beacon.discovery.schema.NodeRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class GetRoutingTable { + + private static final Logger LOG = LoggerFactory.getLogger(GetRoutingTable.class); + public static final int MAX_ROUTING_TABLE_SIZE = 16; + + private final HistoryNetworkInternalAPI historyNetworkInternalAPI; + + public GetRoutingTable(final HistoryNetworkInternalAPI historyNetworkInternalAPI) { + this.historyNetworkInternalAPI = historyNetworkInternalAPI; + } + + private Optional>> execute() { + List> routingTable = historyNetworkInternalAPI.getRoutingTable(); + return Optional.of( + routingTable.stream() + .map( + innerList -> { + List reversed = + innerList.stream() + .map(NodeRecord::getNodeId) + .map(Bytes::toHexString) + .limit(MAX_ROUTING_TABLE_SIZE) + .collect(Collectors.toList()); + Collections.reverse(reversed); // ordered from least-recently to most-recently + return reversed; + }) + .collect(Collectors.toList())); + } + + public static Optional>> execute( + final HistoryNetworkInternalAPI historyNetworkInternalAPI) { + LOG.debug("Executing GetRoutingTable"); + return new GetRoutingTable(historyNetworkInternalAPI).execute(); + } +} diff --git a/core/src/main/java/samba/network/history/routingtable/HistoryRoutingTable.java b/core/src/main/java/samba/network/history/routingtable/HistoryRoutingTable.java index cf988309..e07e444c 100644 --- a/core/src/main/java/samba/network/history/routingtable/HistoryRoutingTable.java +++ b/core/src/main/java/samba/network/history/routingtable/HistoryRoutingTable.java @@ -6,6 +6,7 @@ import java.util.Comparator; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -103,6 +104,11 @@ public boolean isNodeIgnored(NodeRecord nodeRecord) { return this.nodeTable.isNodeIgnored(nodeRecord); } + @Override + public List> getNodeRecordBuckets() { + return this.nodeTable.getNodeRecordBuckets(); + } + @Override public Optional findClosestNodeToKey(Bytes key) { return radiusMap.entrySet().stream() diff --git a/core/src/main/java/samba/network/history/routingtable/RoutingTable.java b/core/src/main/java/samba/network/history/routingtable/RoutingTable.java index 8ed945e9..8e2d1888 100644 --- a/core/src/main/java/samba/network/history/routingtable/RoutingTable.java +++ b/core/src/main/java/samba/network/history/routingtable/RoutingTable.java @@ -1,5 +1,6 @@ package samba.network.history.routingtable; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.stream.Stream; @@ -33,4 +34,7 @@ public interface RoutingTable { boolean isNodeConnected(Bytes nodeId); boolean isNodeIgnored(NodeRecord nodeRecord); + + /** Recently nodes are at the start of the list with older at the end. */ + List> getNodeRecordBuckets(); } diff --git a/core/src/main/java/samba/services/HistoryNetworkMainService.java b/core/src/main/java/samba/services/HistoryNetworkMainService.java index 2c6142aa..cef65b8d 100644 --- a/core/src/main/java/samba/services/HistoryNetworkMainService.java +++ b/core/src/main/java/samba/services/HistoryNetworkMainService.java @@ -189,6 +189,9 @@ private void initJsonRPCService() { RpcMethod.PORTAL_HISTORY_RECURSIVE_FIND_NODES.getMethodName(), new PortalHistoryRecursiveFindNodes(this.historyAPI)); methods.put(RpcMethod.PORTAL_BEACON_STORE.getMethodName(), new PortalBeaconStore()); + methods.put( + RpcMethod.PORTAL_HISTORY_ROUTING_TABLE_INFO.getMethodName(), + new PortalHistoryRoutingTableInfo(this.historyAPI, this.discv5API)); jsonRpcService = Optional.of( diff --git a/core/src/test/java/samba/SimpleIdentitySchemaInterpreter.java b/core/src/test/java/samba/SimpleIdentitySchemaInterpreter.java new file mode 100644 index 00000000..525cb070 --- /dev/null +++ b/core/src/test/java/samba/SimpleIdentitySchemaInterpreter.java @@ -0,0 +1,131 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ +package samba; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.apache.tuweni.bytes.MutableBytes; +import org.apache.tuweni.crypto.SECP256K1.SecretKey; +import org.apache.tuweni.units.bigints.UInt64; +import org.ethereum.beacon.discovery.schema.EnrField; +import org.ethereum.beacon.discovery.schema.IdentitySchema; +import org.ethereum.beacon.discovery.schema.IdentitySchemaInterpreter; +import org.ethereum.beacon.discovery.schema.NodeRecord; +import org.ethereum.beacon.discovery.schema.NodeRecordFactory; + +public class SimpleIdentitySchemaInterpreter implements IdentitySchemaInterpreter { + + public static NodeRecord createNodeRecord(final int nodeId) { + return createNodeRecord(Bytes.ofUnsignedInt(nodeId)); + } + + public static NodeRecord createNodeRecord( + final Bytes nodeId, final InetSocketAddress udpAddress) { + return createNodeRecord( + nodeId, + new EnrField(EnrField.IP_V4, Bytes.wrap(udpAddress.getAddress().getAddress())), + new EnrField(EnrField.UDP, udpAddress.getPort())); + } + + public static NodeRecord createNodeRecord(final Bytes nodeId, final EnrField... extraFields) { + final List fields = new ArrayList<>(List.of(extraFields)); + fields.add(new EnrField(EnrField.ID, IdentitySchema.V4)); + fields.add(new EnrField(EnrField.PKEY_SECP256K1, nodeId)); + return new NodeRecordFactory(new SimpleIdentitySchemaInterpreter()) + .createFromValues(UInt64.ONE, fields); + } + + @Override + public IdentitySchema getScheme() { + return IdentitySchema.V4; + } + + @Override + public void sign(final NodeRecord nodeRecord, final SecretKey secretKey) { + nodeRecord.setSignature(MutableBytes.create(96)); + } + + @Override + public Bytes getNodeId(final NodeRecord nodeRecord) { + Bytes prototype = (Bytes) nodeRecord.get(EnrField.PKEY_SECP256K1); + // Aligning it for correct 32 bytes + if (prototype.size() <= 32) { + return Bytes32.leftPad(prototype); + } else { + return prototype.slice(0, 32); + } + } + + @Override + public Optional getUdpAddress(final NodeRecord nodeRecord) { + try { + final Bytes ipBytes = (Bytes) nodeRecord.get(EnrField.IP_V4); + if (ipBytes == null) { + return Optional.empty(); + } + final InetAddress ipAddress = InetAddress.getByAddress(ipBytes.toArrayUnsafe()); + final int port = (int) nodeRecord.get(EnrField.UDP); + return Optional.of(new InetSocketAddress(ipAddress, port)); + } catch (UnknownHostException e) { + return Optional.empty(); + } + } + + @Override + public Optional getUdp6Address(NodeRecord nodeRecord) { + return Optional.empty(); + } + + @Override + public Optional getTcpAddress(final NodeRecord nodeRecord) { + return Optional.empty(); + } + + @Override + public Optional getTcp6Address(NodeRecord nodeRecord) { + return Optional.empty(); + } + + @Override + public NodeRecord createWithNewAddress( + NodeRecord nodeRecord, + InetSocketAddress inetSocketAddress, + Optional optional, + SecretKey secretKey) { + return null; + } + + @Override + public NodeRecord createWithUpdatedCustomField( + final NodeRecord nodeRecord, + final String fieldName, + final Bytes value, + final SecretKey secretKey) { + final List fields = new ArrayList<>(); + nodeRecord.forEachField( + (key, existingValue) -> { + if (!key.equals(fieldName)) { + fields.add(new EnrField(key, existingValue)); + } + }); + fields.add(new EnrField(fieldName, value)); + final NodeRecord newRecord = NodeRecord.fromValues(this, nodeRecord.getSeq().add(1), fields); + sign(newRecord, secretKey); + return newRecord; + } + + @Override + public Bytes calculateNodeId(final Bytes publicKey) { + final NodeRecord nodeRecord = + createNodeRecord(publicKey, new InetSocketAddress("127.0.0.1", 2)); + return nodeRecord.getNodeId(); + } +} diff --git a/core/src/test/java/samba/TestHelper.java b/core/src/test/java/samba/TestHelper.java index 38d11b30..29658b61 100644 --- a/core/src/test/java/samba/TestHelper.java +++ b/core/src/test/java/samba/TestHelper.java @@ -1,9 +1,18 @@ package samba; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.List; import java.util.Random; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.units.bigints.UInt64; +import org.ethereum.beacon.discovery.schema.EnrField; +import org.ethereum.beacon.discovery.schema.IdentitySchema; import org.ethereum.beacon.discovery.schema.NodeRecord; import org.ethereum.beacon.discovery.schema.NodeRecordBuilder; +import org.ethereum.beacon.discovery.schema.NodeRecordFactory; import org.ethereum.beacon.discovery.util.Functions; public class TestHelper { @@ -13,4 +22,31 @@ public static NodeRecord createNodeRecord() { .secretKey(Functions.randomKeyPair(new Random(new Random().nextInt())).secretKey()) .build(); } + + public static NodeRecord createNodeAtDistance(final Bytes sourceNode, final int distance) { + final BitSet bits = BitSet.valueOf(sourceNode.reverse().toArray()); + bits.flip(distance - 1); + final byte[] targetNodeId = new byte[sourceNode.size()]; + final byte[] src = bits.toByteArray(); + System.arraycopy(src, 0, targetNodeId, 0, src.length); + final Bytes nodeId = Bytes.wrap(targetNodeId).reverse(); + return SimpleIdentitySchemaInterpreter.createNodeRecord( + nodeId, new InetSocketAddress("127.0.0.1", 2)); + } + + public static NodeRecord createNodeRecord( + final Bytes nodeId, final InetSocketAddress udpAddress) { + return createNodeRecord( + nodeId, + new EnrField(EnrField.IP_V4, Bytes.wrap(udpAddress.getAddress().getAddress())), + new EnrField(EnrField.UDP, udpAddress.getPort())); + } + + public static NodeRecord createNodeRecord(final Bytes nodeId, final EnrField... extraFields) { + final List fields = new ArrayList<>(List.of(extraFields)); + fields.add(new EnrField(EnrField.ID, IdentitySchema.V4)); + fields.add(new EnrField(EnrField.PKEY_SECP256K1, nodeId)); + return new NodeRecordFactory(new SimpleIdentitySchemaInterpreter()) + .createFromValues(UInt64.ONE, fields); + } } diff --git a/core/src/test/java/samba/network/history/HistoryRoutingTableTest.java b/core/src/test/java/samba/network/history/HistoryRoutingTableTest.java index fe3d387c..8d1b9d96 100644 --- a/core/src/test/java/samba/network/history/HistoryRoutingTableTest.java +++ b/core/src/test/java/samba/network/history/HistoryRoutingTableTest.java @@ -1,5 +1,6 @@ package samba.network.history; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; @@ -71,4 +72,18 @@ public void testFindClosestNodesToContentKeyInRadius() { assertEquals(2, foundNodes.size()); assertEquals(Set.of(this.nodes.get(0), this.nodes.get(2)), foundNodes); } + + @Test + void testGetNodeRecordBuckets() { + + var mostRecently = TestHelper.createNodeAtDistance(homeNode.getNodeId(), 1); + var internalBuckets = this.routingTable.getNodeRecordBuckets(); + assertThat(internalBuckets.stream().mapToInt(List::size).sum()).isEqualTo(14); + + this.routingTable.addOrUpdateNode(mostRecently); + internalBuckets = this.routingTable.getNodeRecordBuckets(); + + assertThat(internalBuckets.stream().mapToInt(List::size).sum()).isEqualTo(15); + assertThat(internalBuckets.get(1).getFirst()).isEqualTo(mostRecently); + } } diff --git a/core/src/test/java/samba/services/jsonrpc/methods/history/PortalHistoryRoutingTableInfoTest.java b/core/src/test/java/samba/services/jsonrpc/methods/history/PortalHistoryRoutingTableInfoTest.java new file mode 100644 index 00000000..87200d39 --- /dev/null +++ b/core/src/test/java/samba/services/jsonrpc/methods/history/PortalHistoryRoutingTableInfoTest.java @@ -0,0 +1,116 @@ +package samba.services.jsonrpc.methods.history; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import samba.api.Discv5APIClient; +import samba.api.HistoryAPIClient; +import samba.api.jsonrpc.PortalHistoryRoutingTableInfo; +import samba.api.jsonrpc.results.NodeInfo; +import samba.api.jsonrpc.results.RoutingTableInfoResult; +import samba.jsonrpc.reponse.JsonRpcErrorResponse; +import samba.jsonrpc.reponse.JsonRpcRequest; +import samba.jsonrpc.reponse.JsonRpcRequestContext; +import samba.jsonrpc.reponse.JsonRpcResponse; +import samba.jsonrpc.reponse.JsonRpcSuccessResponse; +import samba.jsonrpc.reponse.RpcErrorType; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.ethereum.beacon.discovery.schema.NodeRecord; +import org.ethereum.beacon.discovery.schema.NodeRecordFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class PortalHistoryRoutingTableInfoTest { + + private final String JSON_RPC_VERSION = "2.0"; + private final String PORTAL_HISTORY_ROUTING_TABLE_INFO = "portal_historyRoutingTableInfo"; + private final NodeRecord nodeRecord = + NodeRecordFactory.DEFAULT.fromEnr( + "enr:-LS4QOhHz1hd6Sg6dAtYL1XDsMN-8Quk0dmH_RhY50nVAGApdpEcK15YxNZhhDFIqNAACi8E3H1GIbtKgQsaM2TDVkyEZ1t3LGOqdCBjMjhjMzFmODViNTU4NDM0MWE0ZDQ0NTliODg2M2VjYThkOTRhY2Q2gmlkgnY0gmlwhKwRAAWJc2VjcDI1NmsxoQLv0EURHW2Rbcuk5hmsN7ZjorMOktgSBDB6n_kYOo-wc4N1ZHCCIzE"); + + private HistoryAPIClient historyAPIClient; + private Discv5APIClient discv5APIClient; + private PortalHistoryRoutingTableInfo method; + + @BeforeEach + public void before() { + this.discv5APIClient = mock(Discv5APIClient.class); + this.historyAPIClient = mock(HistoryAPIClient.class); + method = new PortalHistoryRoutingTableInfo(this.historyAPIClient, this.discv5APIClient); + } + + @Test + public void shouldReturnCorrectMethodName() { + assertThat(method.getName()).isEqualTo(PORTAL_HISTORY_ROUTING_TABLE_INFO); + } + + @Test + public void shouldReturnCorrectResult() { + final JsonRpcRequestContext request = + new JsonRpcRequestContext( + new JsonRpcRequest( + JSON_RPC_VERSION, PORTAL_HISTORY_ROUTING_TABLE_INFO, new Object[] {})); + final NodeInfo info = buildNodeInfo(nodeRecord); + List> routingTable = new ArrayList<>(); + when(historyAPIClient.getRoutingTable()).thenReturn(Optional.of(routingTable)); + when(discv5APIClient.getNodeInfo()).thenReturn(Optional.of(info)); + + JsonRpcResponse expected = + new JsonRpcSuccessResponse( + request.getRequest().getId(), + new RoutingTableInfoResult(info.getNodeId(), routingTable)); + JsonRpcResponse actual = method.response(request); + assertNotNull(actual); + assertThat(actual).usingRecursiveComparison().isEqualTo(expected); + + when(discv5APIClient.getNodeInfo()).thenReturn(Optional.empty()); + expected = new JsonRpcErrorResponse(request.getRequest().getId(), RpcErrorType.INVALID_REQUEST); + actual = method.response(request); + assertNotNull(actual); + assertThat(actual).usingRecursiveComparison().isEqualTo(expected); + } + + @Test + public void shouldReturnInvalidResult() { + final JsonRpcRequestContext request = + new JsonRpcRequestContext( + new JsonRpcRequest( + JSON_RPC_VERSION, PORTAL_HISTORY_ROUTING_TABLE_INFO, new Object[] {})); + final NodeInfo info = buildNodeInfo(nodeRecord); + when(historyAPIClient.getRoutingTable()).thenReturn(Optional.empty()); + when(discv5APIClient.getNodeInfo()).thenReturn(Optional.of(info)); + + JsonRpcResponse expected = + new JsonRpcErrorResponse(request.getRequest().getId(), RpcErrorType.INVALID_REQUEST); + JsonRpcResponse actual = method.response(request); + assertNotNull(actual); + assertThat(actual).usingRecursiveComparison().isEqualTo(expected); + + List> routingTable = new ArrayList<>(); + when(historyAPIClient.getRoutingTable()).thenReturn(Optional.of(routingTable)); + when(discv5APIClient.getNodeInfo()).thenReturn(Optional.empty()); + + expected = new JsonRpcErrorResponse(request.getRequest().getId(), RpcErrorType.INVALID_REQUEST); + actual = method.response(request); + assertNotNull(actual); + assertThat(actual).usingRecursiveComparison().isEqualTo(expected); + + when(historyAPIClient.getRoutingTable()).thenReturn(Optional.empty()); + when(discv5APIClient.getNodeInfo()).thenReturn(Optional.empty()); + + expected = new JsonRpcErrorResponse(request.getRequest().getId(), RpcErrorType.INVALID_REQUEST); + actual = method.response(request); + assertNotNull(actual); + assertThat(actual).usingRecursiveComparison().isEqualTo(expected); + } + + private NodeInfo buildNodeInfo(NodeRecord nodeRecord) { + return new NodeInfo(nodeRecord.asEnr(), nodeRecord.getNodeId().toHexString()); + } +}