diff --git a/hathor/p2p/resources/status.py b/hathor/p2p/resources/status.py index bda60cc1c..7287c6c1a 100644 --- a/hathor/p2p/resources/status.py +++ b/hathor/p2p/resources/status.py @@ -17,8 +17,12 @@ import hathor from hathor.api_util import Resource, set_cors from hathor.cli.openapi_files.register import register_resource +from hathor.conf import HathorSettings +from hathor.p2p.utils import to_serializable_best_blockchain from hathor.util import json_dumpb +settings = HathorSettings() + @register_resource class StatusResource(Resource): @@ -70,6 +74,7 @@ def render_GET(self, request): 'plugins': status, 'warning_flags': [flag.value for flag in conn.warning_flags], 'protocol_version': str(conn.sync_version), + 'peer_best_blockchain': to_serializable_best_blockchain(conn.state.peer_best_blockchain), }) known_peers = [] @@ -89,6 +94,8 @@ def render_GET(self, request): best_block_tips.append({'hash': tx.hash_hex, 'height': meta.height}) best_block = self.manager.tx_storage.get_best_block() + raw_best_blockchain = self.manager.tx_storage.get_n_height_tips(settings.DEFAULT_BEST_BLOCKCHAIN_BLOCKS) + best_blockchain = to_serializable_best_blockchain(raw_best_blockchain) data = { 'server': { @@ -114,11 +121,36 @@ def render_GET(self, request): 'hash': best_block.hash_hex, 'height': best_block.get_metadata().height, }, + 'best_blockchain': best_blockchain, } } return json_dumpb(data) +_openapi_height_info = [59, '0000045de9ac8365c43ccc96222873cb80c340c6c9c8949b56d2e2e51b6a3dbe'] +_openapi_connected_peer = { + 'id': '5578ab3bcaa861fb9d07135b8b167dd230d4487b147be8fd2c94a79bd349d123', + 'app_version': 'Hathor v0.14.0-beta', + 'uptime': 118.37029600143433, + 'address': '192.168.1.1:54321', + 'state': 'READY', + 'last_message': 1539271481, + 'plugins': { + 'node-sync-timestamp': { + 'is_enabled': True, + 'latest_timestamp': 1685310912, + 'synced_timestamp': 1685310912 + } + }, + 'warning_flags': ['no_entrypoints'], + 'protocol_version': 'sync-v1.1', + 'peer_best_blockchain': [_openapi_height_info] +} +_openapi_connecting_peer = { + 'deferred': '>', # noqa + 'address': '192.168.1.1:54321' +} + StatusResource.openapi = { '/status': { 'x-visibility': 'public', @@ -164,7 +196,7 @@ def render_GET(self, request): }, 'known_peers': [], 'connections': { - 'connected_peers': [], + 'connected_peers': [_openapi_connected_peer], 'handshaking_peers': [ { 'address': '192.168.1.1:54321', @@ -173,28 +205,24 @@ def render_GET(self, request): 'app_version': 'Unknown' } ], - 'connecting_peers': [ - { - 'deferred': ('>'), - 'address': '192.168.1.1:54321' - } - ] + 'connecting_peers': [_openapi_connecting_peer] }, 'dag': { 'first_timestamp': 1539271481, 'latest_timestamp': 1539271483, 'best_block_tips': [ { - 'hash': '000007eb968a6cdf0499e2d033faf1e163e0dc9cf41876acad4d421836972038', # noqa: E501 + 'hash': + '000007eb968a6cdf0499e2d033faf1e163e0dc9cf41876acad4d421836972038', # noqa 'height': 0 } ], 'best_block': { - 'hash': '000007eb968a6cdf0499e2d033faf1e163e0dc9cf41876acad4d421836972038', # noqa: E501 + 'hash': + '000007eb968a6cdf0499e2d033faf1e163e0dc9cf41876acad4d421836972038', # noqa 'height': 0 - } + }, + 'best_blockchain': [_openapi_height_info] } } } diff --git a/hathor/p2p/states/ready.py b/hathor/p2p/states/ready.py index b6813c9c4..19d5bddc0 100644 --- a/hathor/p2p/states/ready.py +++ b/hathor/p2p/states/ready.py @@ -23,8 +23,8 @@ from hathor.p2p.messages import ProtocolMessages from hathor.p2p.peer_id import PeerId from hathor.p2p.states.base import BaseState -from hathor.p2p.states.utils import to_height_info from hathor.p2p.sync_agent import SyncAgent +from hathor.p2p.utils import to_height_info, to_serializable_best_blockchain from hathor.transaction import BaseTransaction from hathor.util import json_dumps, json_loads @@ -233,7 +233,6 @@ def handle_get_best_blockchain(self, payload: str) -> None: f'N out of bounds. Valid range: [1, {settings.MAX_BEST_BLOCKCHAIN_BLOCKS}].' ) return - self.protocol.my_peer best_blockchain = self.protocol.node.tx_storage.get_n_height_tips(n_blocks) self.send_best_blockchain(best_blockchain) @@ -241,7 +240,7 @@ def handle_get_best_blockchain(self, payload: str) -> None: def send_best_blockchain(self, best_blockchain: list[HeightInfo]) -> None: """ Send a BEST-BLOCKCHAIN command with a best blockchain of N blocks. """ - serialiable_best_blockchain = [(hi.height, hi.id.hex()) for hi in best_blockchain] + serialiable_best_blockchain = to_serializable_best_blockchain(best_blockchain) self.send_message(ProtocolMessages.BEST_BLOCKCHAIN, json_dumps(serialiable_best_blockchain)) def handle_best_blockchain(self, payload: str) -> None: diff --git a/hathor/p2p/states/utils.py b/hathor/p2p/states/utils.py deleted file mode 100644 index 317077f3b..000000000 --- a/hathor/p2p/states/utils.py +++ /dev/null @@ -1,24 +0,0 @@ -import re - -from hathor.indexes.height_index import HeightInfo - - -def to_height_info(raw: tuple[int, str]) -> HeightInfo: - """ Instantiate HeightInfo from a literal tuple. - """ - if not (isinstance(raw, list) and len(raw) == 2): - raise ValueError(f"block_info_raw must be a tuple with length 3. We got {raw}.") - - height, id = raw - - if not isinstance(id, str): - raise ValueError(f"hash_hex must be a string. We got {id}.") - hash_pattern = r'[a-fA-F\d]{64}' - if not re.match(hash_pattern, id): - raise ValueError(f"hash_hex must be valid. We got {id}.") - if not isinstance(height, int): - raise ValueError(f"height must be an integer. We got {height}.") - if height < 0: - raise ValueError(f"height must greater than or equal to 0. We got {height}.") - - return HeightInfo(height, bytes.fromhex(id)) diff --git a/hathor/p2p/utils.py b/hathor/p2p/utils.py index e9c778807..745ab05ee 100644 --- a/hathor/p2p/utils.py +++ b/hathor/p2p/utils.py @@ -13,6 +13,7 @@ # limitations under the License. import datetime +import re from typing import Any, Generator, Optional from urllib.parse import parse_qs, urlparse @@ -28,6 +29,7 @@ from twisted.internet.interfaces import IAddress from hathor.conf import HathorSettings +from hathor.indexes.height_index import HeightInfo from hathor.p2p.peer_discovery import DNSPeerDiscovery from hathor.transaction.genesis import GENESIS_HASH @@ -200,3 +202,30 @@ def format_address(addr: IAddress) -> str: return f'{host}:{port}' else: return str(addr) + + +def to_height_info(raw: tuple[int, str]) -> HeightInfo: + """ Instantiate HeightInfo from a literal tuple. + """ + if not (isinstance(raw, list) and len(raw) == 2): + raise ValueError(f"height_info_raw must be a tuple with length 2. We got {raw}.") + + height, id = raw + + if not isinstance(id, str): + raise ValueError(f"id (hash) must be a string. We got {id}.") + hash_pattern = r'[a-fA-F\d]{64}' + if not re.match(hash_pattern, id): + raise ValueError(f"id (hash) must be valid. We got {id}.") + if not isinstance(height, int): + raise ValueError(f"height must be an integer. We got {height}.") + if height < 0: + raise ValueError(f"height must be greater than or equal to 0. We got {height}.") + + return HeightInfo(height, bytes.fromhex(id)) + + +def to_serializable_best_blockchain(best_blockchain: list[HeightInfo]) -> list[tuple[int, str]]: + """ Converts the list of HeightInfo to a tuple list that can be serializable to json afterwards. + """ + return [(hi.height, hi.id.hex()) for hi in best_blockchain] diff --git a/tests/p2p/test_get_best_blockchain.py b/tests/p2p/test_get_best_blockchain.py index 367fdc70e..a37e4a742 100644 --- a/tests/p2p/test_get_best_blockchain.py +++ b/tests/p2p/test_get_best_blockchain.py @@ -1,12 +1,17 @@ +from twisted.internet.defer import inlineCallbacks + from hathor.conf import HathorSettings from hathor.indexes.height_index import HeightInfo from hathor.manager import DEFAULT_CAPABILITIES from hathor.p2p.messages import ProtocolMessages +from hathor.p2p.resources import StatusResource from hathor.p2p.states import ReadyState +from hathor.p2p.utils import to_height_info from hathor.simulator import FakeConnection from hathor.simulator.trigger import StopAfterNMinedBlocks from hathor.util import json_dumps from tests import unittest +from tests.resources.base_resource import StubSite from tests.simulation.base import SimulatorTestCase settings = HathorSettings() @@ -339,6 +344,106 @@ def test_stop_looping_on_exit(self): self.assertIsNotNone(state2.lc_get_best_blockchain) self.assertFalse(state2.lc_get_best_blockchain.running) + @inlineCallbacks + def test_best_blockchain_from_status_resource(self): + manager1 = self.create_peer() + manager2 = self.create_peer() + conn12 = FakeConnection(manager1, manager2, latency=0.05) + self.simulator.add_connection(conn12) + self.simulator.run(60) + + # check /status before generate blocks + self.web = StubSite(StatusResource(manager1)) + response = yield self.web.get("status") + data = response.json_value() + connections = data.get('connections') + self.assertEqual(len(connections['connected_peers']), 1) + dag = data.get('dag') + + # connected_peers + # assert there is the genesis block + peer_best_blockchain = connections['connected_peers'][0]['peer_best_blockchain'] + self.assertEqual(len(peer_best_blockchain), 1) + # assert the height_info height is from genesis + raw_height_info_height = peer_best_blockchain[0][0] + self.assertEqual(raw_height_info_height, 0) + + # dag + # assert there is the genesis block + peer_best_blockchain = dag['best_blockchain'] + self.assertEqual(len(peer_best_blockchain), 1) + # assert the height_info height is from genesis + raw_height_info_height = peer_best_blockchain[0][0] + self.assertEqual(raw_height_info_height, 0) + + # mine 20 blocks + miner = self.simulator.create_miner(manager1, hashpower=1e6) + miner.start() + trigger = StopAfterNMinedBlocks(miner, quantity=20) + self.assertTrue(self.simulator.run(1440, trigger=trigger)) + miner.stop() + # let the blocks to propagate + self.simulator.run(60) + + # check /status after mine blocks + response = yield self.web.get("status") + data = response.json_value() + connections = data.get('connections') + self.assertEqual(len(connections['connected_peers']), 1) + dag = data.get('dag') + + # connected_peers + # assert default peer_best_blockchain length + peer_best_blockchain = connections['connected_peers'][0]['peer_best_blockchain'] + self.assertEqual(len(peer_best_blockchain), settings.DEFAULT_BEST_BLOCKCHAIN_BLOCKS) + + # assert a raw_height_info can be converted to HeightInfo + try: + raw_height_info = peer_best_blockchain[0] + height_info = to_height_info(raw_height_info) + # assert the first element height is from the lastest block mined + self.assertEqual(height_info.height, 20) + except ValueError: + self.fail('Block info not valid') + + # assert decreasing order for a sequence of heights + height_sequence = [hi[0] for hi in peer_best_blockchain] + try: + self.assertTrue(check_decreasing_monotonicity(height_sequence)) + except ValueError as e: + self.fail(str(e)) + + # dag + # assert default peer_best_blockchain length + peer_best_blockchain = dag['best_blockchain'] + self.assertEqual(len(peer_best_blockchain), settings.DEFAULT_BEST_BLOCKCHAIN_BLOCKS) + + # assert a raw_height_info can be converted to HeightInfo + try: + raw_height_info = peer_best_blockchain[0] + height_info = to_height_info(raw_height_info) + # assert the first element height is from the lastest block mined + self.assertEqual(height_info.height, 20) + except ValueError: + self.fail('Block info not valid') + + # assert decreasing order for a sequence of heights + height_sequence = [hi[0] for hi in peer_best_blockchain] + try: + self.assertTrue(check_decreasing_monotonicity(height_sequence)) + except ValueError as e: + self.fail(str(e)) + + +def check_decreasing_monotonicity(sequence: list[int]) -> bool: + """Check if a sequence is monotonic and is decreasing. Raise an exception otherwise. + """ + n = len(sequence) + for i in range(1, n): + if sequence[i] >= sequence[i-1]: + raise ValueError(f'Sequence not monotonic. Value {sequence[i]} >= {sequence[i-1]}. Index: {i}.') + return True + class SyncV1GetBestBlockchainTestCase(unittest.SyncV1Params, BaseGetBestBlockchainTestCase): __test__ = True