diff --git a/crates/floresta-electrum/src/electrum_protocol.rs b/crates/floresta-electrum/src/electrum_protocol.rs index 7ee800763..18c81a54e 100644 --- a/crates/floresta-electrum/src/electrum_protocol.rs +++ b/crates/floresta-electrum/src/electrum_protocol.rs @@ -291,7 +291,7 @@ impl ElectrumServer { json_rpc_res!(request, { "count": count, - "hex": String::from_iter(headers), + "headers": headers.collect::>(), "max": MAX_COUNT, }) } diff --git a/pyproject.toml b/pyproject.toml index aa55740f7..e5ffae448 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ markers = [ "example: marks tests that demonstrate how to use the Floresta test framework", "florestad: marks tests specific to the Florestad daemon", "rpc: marks tests focused on RPC calls", + "electrum: marks tests related to Electrum server interactions", ] minversion = "9.0" diff --git a/tests/electrum/blockchain_block_header.py b/tests/electrum/blockchain_block_header.py new file mode 100644 index 000000000..7b8d2f25a --- /dev/null +++ b/tests/electrum/blockchain_block_header.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: MIT OR Apache-2.0 + +""" +Tests for block header retrieval and validation in the Electrum server. + +This tests the blockchain.block.header endpoint which returns a block header +at a given height, optionally with a merkle proof for verification. +""" + +import random +import pytest +from test_framework.util import wait_until + +MINE_BLOCKS = 100 + + +@pytest.mark.electrum +def test_block_header(florestad_utreexod): + """Test block header retrieval and validation.""" + florestad, utreexod = florestad_utreexod + + utreexod.rpc.generate(MINE_BLOCKS) + wait_until(lambda: florestad.rpc.get_block_count() == MINE_BLOCKS) + + with pytest.raises(ValueError): + florestad.electrum.block_header(MINE_BLOCKS + 1) + + with pytest.raises(ValueError): + florestad.electrum.block_header(-1) + + compare_headers(florestad, 0) + compare_headers(florestad, MINE_BLOCKS) + + random_height = random.randint(1, MINE_BLOCKS - 1) + compare_headers(florestad, random_height) + + +def compare_headers(florestad, height): + """Helper function to compare block headers from Electrum and RPC.""" + electrum_header = florestad.electrum.block_header(height) + + block_hash = florestad.rpc.get_blockhash(height) + rpc_header = florestad.rpc.get_blockheader(block_hash, verbosity=False) + + assert electrum_header == rpc_header diff --git a/tests/electrum/blockchain_block_headers.py b/tests/electrum/blockchain_block_headers.py new file mode 100644 index 000000000..9bd26be9d --- /dev/null +++ b/tests/electrum/blockchain_block_headers.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: MIT OR Apache-2.0 + +""" +Tests for block headers retrieval and validation in the Electrum server. + +This tests the blockchain.block.headers endpoint which returns a chunk +of block headers from the main chain. +""" + +import random +import pytest +from test_framework.util import wait_until + +MINE_BLOCKS = 100 +MAX_HEADERS = 2016 + + +@pytest.mark.electrum +def test_block_headers(setup_logging, florestad_utreexod): + """Test block headers retrieval and validation.""" + log = setup_logging + florestad, utreexod = florestad_utreexod + + utreexod.rpc.generate(MINE_BLOCKS) + wait_until(lambda: florestad.rpc.get_block_count() == MINE_BLOCKS) + + log.info("Testing out-of-range request...") + response = florestad.electrum.block_headers(MINE_BLOCKS + 1, 10) + assert response["count"] == 0 + assert response["headers"] == [] + assert response["max"] == MAX_HEADERS + + log.info("Testing invalid parameters...") + with pytest.raises(ValueError): + florestad.electrum.block_headers(-1, 10) + + with pytest.raises(ValueError): + florestad.electrum.block_headers(0, -1) + + log.info("Testing valid requests...") + compare_headers_range(florestad, 0, 10) + compare_headers_range(florestad, MINE_BLOCKS - 10, 10) + + log.info("Testing random range...") + random_start = random.randint(1, MINE_BLOCKS - 10) + compare_headers_range(florestad, random_start, 10) + + log.info("Testing max count limit...") + response = florestad.electrum.block_headers(0, MAX_HEADERS * 2) + assert response["count"] <= response["max"] + assert response["max"] == MAX_HEADERS + assert response["count"] <= MAX_HEADERS + + +def compare_headers_range(florestad, start_height, count): + """Compare block headers from Electrum and RPC for a range.""" + response = florestad.electrum.block_headers(start_height, count) + + # Validate response structure + assert "count" in response + assert "headers" in response + assert "max" in response + assert response["count"] == len(response["headers"]) + assert response["max"] == MAX_HEADERS + + # Compare each header with RPC + for i, header in enumerate(response["headers"]): + height = start_height + i + block_hash = florestad.rpc.get_blockhash(height) + rpc_header = florestad.rpc.get_blockheader(block_hash, verbosity=False) + + assert header == rpc_header diff --git a/tests/example/electrum.py b/tests/example/electrum.py index 72008b5a6..c6bb3601c 100644 --- a/tests/example/electrum.py +++ b/tests/example/electrum.py @@ -25,5 +25,5 @@ def test_electrum(florestad_node): """ rpc_response = florestad_node.electrum.get_version() - assert rpc_response["result"][0] == EXPECTED_VERSION[0] - assert rpc_response["result"][1] == EXPECTED_VERSION[1] + assert rpc_response[0] == EXPECTED_VERSION[0] + assert rpc_response[1] == EXPECTED_VERSION[1] diff --git a/tests/floresta-cli/getbestblockhash.py b/tests/floresta-cli/getbestblockhash.py index a4d590149..7091f5ee8 100644 --- a/tests/floresta-cli/getbestblockhash.py +++ b/tests/floresta-cli/getbestblockhash.py @@ -7,10 +7,9 @@ and utreexod, respectively. """ -import time import pytest -TIMEOUT_SECONDS = 20 +from test_framework.util import wait_until @pytest.mark.rpc @@ -27,14 +26,11 @@ def test_get_best_block_hash(florestad_utreexod): assert floresta_best_block == utreexo_best_block utreexod.rpc.generate(10) - end = time.time() + TIMEOUT_SECONDS - while time.time() < end: - floresta_block = florestad.rpc.get_block_count() - utreexo_block = utreexod.rpc.get_block_count() - if floresta_block == utreexo_block: - break - - time.sleep(1) + + wait_until( + predicate=lambda: florestad.rpc.get_block_count() + == utreexod.rpc.get_block_count() + ) utreexo_chain = utreexod.rpc.get_blockchain_info() floresta_best_block = florestad.rpc.get_bestblockhash() diff --git a/tests/floresta-cli/getblock.py b/tests/floresta-cli/getblock.py index a90ff4db7..2a7a42572 100644 --- a/tests/floresta-cli/getblock.py +++ b/tests/floresta-cli/getblock.py @@ -9,10 +9,9 @@ import time import random from typing import Any - import pytest -TIMEOUT_SECONDS = 20 +from test_framework.util import wait_until class TestGetBlock: @@ -44,14 +43,10 @@ def test_get_block( self.node_manager.connect_nodes(self.florestad, self.bitcoind) block_count = self.bitcoind.rpc.get_block_count() - end = time.time() + TIMEOUT_SECONDS - while time.time() < end: - floresta_count = self.florestad.rpc.get_block_count() - if floresta_count == block_count: - break - time.sleep(0.5) - - assert floresta_count == block_count + + wait_until( + predicate=lambda: self.florestad.rpc.get_block_count() == block_count + ) self.log.info("Testing getblock RPC in the genesis block") self.compare_block(0) diff --git a/tests/floresta-cli/getblockcount.py b/tests/floresta-cli/getblockcount.py index e68cea267..67a04d0dc 100644 --- a/tests/floresta-cli/getblockcount.py +++ b/tests/floresta-cli/getblockcount.py @@ -6,11 +6,10 @@ `blocks` and `height/validated` fields given in `getblockchaininfo` of utreexod/bitcoind and floresta, respectively""" -import time import pytest +from test_framework.util import wait_until MINE_BLOCKS = 10 -TIMEOUT_SECONDS = 20 @pytest.mark.rpc @@ -28,15 +27,11 @@ def test_get_block_count(florestad_utreexod): # Mine blocks with utreexod utreexod.rpc.generate(MINE_BLOCKS) - timeout = time.time() + TIMEOUT_SECONDS - while time.time() < timeout: - if ( - florestad.rpc.get_block_count() - == utreexod.rpc.get_block_count() - == MINE_BLOCKS - ): - break - time.sleep(1) + wait_until( + predicate=lambda: florestad.rpc.get_block_count() + == utreexod.rpc.get_block_count() + == MINE_BLOCKS + ) # Get final block counts final_florestad_count = florestad.rpc.get_block_count() diff --git a/tests/floresta-cli/getblockhash.py b/tests/floresta-cli/getblockhash.py index 7ebbdf055..f5579bdc5 100644 --- a/tests/floresta-cli/getblockhash.py +++ b/tests/floresta-cli/getblockhash.py @@ -6,13 +6,12 @@ This functional test cli utility to interact with a Floresta node with `getblockhash` """ -import time import pytest from test_framework.constants import GENESIS_BLOCK_HASH +from test_framework.util import wait_until MINED_BLOCKS = 10 -TIMEOUT = 20 @pytest.mark.rpc @@ -30,15 +29,11 @@ def test_get_block_hash(florestad_utreexod): # Mine blocks with utreexod utreexod.rpc.generate(MINED_BLOCKS) - timeout = time.time() + TIMEOUT - while time.time() < timeout: - if ( - florestad.rpc.get_block_count() - == utreexod.rpc.get_block_count() - == MINED_BLOCKS - ): - break - time.sleep(1) + wait_until( + predicate=lambda: florestad.rpc.get_block_count() + == utreexod.rpc.get_block_count() + == MINED_BLOCKS + ) # Get final block hashes final_florestad_hash = florestad.rpc.get_blockhash(MINED_BLOCKS) diff --git a/tests/floresta-cli/gettxout.py b/tests/floresta-cli/gettxout.py index 7959579e1..0fb969df7 100644 --- a/tests/floresta-cli/gettxout.py +++ b/tests/floresta-cli/gettxout.py @@ -6,10 +6,9 @@ This functional test cli utility to interact with a Floresta node with `gettxout` command. """ -import time import pytest -TIMEOUT_SECONDS = 120 +from test_framework.util import wait_until # pylint: disable=too-many-locals @@ -27,19 +26,8 @@ def test_get_txout(setup_logging, florestad_bitcoind_utreexod_with_chain): best_block_hash = utreexod.rpc.get_blockhash(blocks) log.info("Waiting for Floresta and Bitcoind to sync with Utreexod...") - timeout = time.time() + TIMEOUT_SECONDS - while time.time() < timeout: - floresta_info = florestad.rpc.get_blockchain_info() - if ( - floresta_info["height"] - == utreexod.rpc.get_block_count() - == bitcoind.rpc.get_block_count() - == blocks - and not floresta_info["ibd"] - ): - break - time.sleep(1) + def check_sync(): # Forcing a re-fetch of the block from the peer try: bitcoind.rpc.get_block_from_peer(best_block_hash, peer_id) @@ -47,7 +35,18 @@ def test_get_txout(setup_logging, florestad_bitcoind_utreexod_with_chain): except Exception as e: log.error(f"Error fetching block from peer: {e}") - assert floresta_info["height"] == blocks and not floresta_info["ibd"] + floresta_info = florestad.rpc.get_blockchain_info() + return ( + floresta_info["height"] + == utreexod.rpc.get_block_count() + == bitcoind.rpc.get_block_count() + and not floresta_info["ibd"] + ) + + wait_until( + check_sync, + error_msg="Floresta and Bitcoind did not sync with Utreexod within the timeout period.", + ) log.info("Comparing gettxout results between Floresta and Bitcoind...") for height in range(2, blocks): diff --git a/tests/florestad/reorg_chain.py b/tests/florestad/reorg_chain.py index bf0eab4a7..7ea4702a6 100644 --- a/tests/florestad/reorg_chain.py +++ b/tests/florestad/reorg_chain.py @@ -9,9 +9,10 @@ accumulator to make sure they are the same. """ -import time import pytest +from test_framework.util import wait_until + @pytest.mark.florestad def test_reorg_chain(setup_logging, florestad_utreexod): @@ -74,16 +75,8 @@ def mine_blocks(self, blocks): self.log.info(f"Utreexod node mine {blocks} blocks") self.utreexod.rpc.generate(blocks) - timeout = 30 - end = time.time() + timeout - while time.time() < end: - florestad_block = self.florestad.rpc.get_block_count() - utreexod_block = self.utreexod.rpc.get_block_count() - if florestad_block == utreexod_block: - self.log.info(f"Nodes are in sync: {florestad_block} blocks") - break - - time.sleep(1) - - if florestad_block != utreexod_block: - pytest.fail("Florestad node did not sync with Utreexod node in time") + wait_until( + predicate=lambda: self.florestad.rpc.get_block_count() + == self.utreexod.rpc.get_block_count(), + error_msg="Florestad node did not sync with Utreexod node in time.", + ) diff --git a/tests/florestad/tls.py b/tests/florestad/tls.py index 9c8198e1a..f975ede83 100644 --- a/tests/florestad/tls.py +++ b/tests/florestad/tls.py @@ -21,6 +21,4 @@ def test_tls(add_node_with_tls): assert florestad.electrum.tls response = florestad.electrum.ping() - assert response["result"] is None - assert response["id"] == 0 - assert response["jsonrpc"] == "2.0" + assert response is None diff --git a/tests/test_framework/__init__.py b/tests/test_framework/__init__.py index 000695a18..26f3a19ab 100644 --- a/tests/test_framework/__init__.py +++ b/tests/test_framework/__init__.py @@ -35,7 +35,7 @@ from test_framework.rpc import ConfigRPC from test_framework.electrum import ConfigElectrum, ConfigTls from test_framework.node import Node, NodeType -from test_framework.util import Utility +from test_framework.util import Utility, wait_until # pylint: disable=too-many-public-methods @@ -274,6 +274,9 @@ def check_connection(self, peer_one: Node, peer_two: Node, is_connected: bool): f"Peer one running: {peer_one_running}, Peer two running: {peer_two_running}" ) + # Send pings to both peers to trigger a peer state update + self._send_peer_pings(peer_one, peer_two) + peer_two_in_peer_one = ( peer_one.is_peer_connected(peer_two) if peer_one_running else False ) @@ -293,42 +296,40 @@ def wait_for_peers_connections( Wait for two peers to connect/disconnect to each other. """ attempts = 0 - timeout = time.time() + 30 - while time.time() < timeout: - if self.check_connection(peer_one, peer_two, is_connected): - self.log.debug( - f"Peers {peer_one.variant} and {peer_two.variant} are in the expected " - f"connection state." - ) - return - if attempts < 10: + def check_peers_connection(): + nonlocal attempts + + if attempts > 10: time.sleep(1) - else: - time.sleep(2) attempts += 1 - # Send a ping to both peers to trigger a peer state update - if peer_one.daemon.is_running: - peer_one.rpc.ping() - self.log.debug( - f"Peer one {peer_one.variant} is connected to peer two {peer_two.variant}: " - f"{peer_one.is_peer_connected(peer_two)}" - ) - - if peer_two.daemon.is_running: - peer_two.rpc.ping() - self.log.debug( - f"Peer two {peer_two.variant} is connected to peer one {peer_one.variant}: " - f"{peer_two.is_peer_connected(peer_one)}" - ) - - raise AssertionError( - f"Peers {peer_one.variant} and {peer_two.variant} failed to reach the expected " - f"connection state within the timeout. Expected connected: {is_connected}." + return self.check_connection(peer_one, peer_two, is_connected) + + wait_until(predicate=check_peers_connection) + + self.log.debug( + f"Peers {peer_one.variant} and {peer_two.variant} are " + f"{'connected' if is_connected else 'disconnected'}" ) + def _send_peer_pings(self, peer_one: Node, peer_two: Node): + """Send pings to both peers and log connection status.""" + if peer_one.daemon.is_running: + peer_one.rpc.ping() + self.log.debug( + f"Peer one {peer_one.variant} is connected to peer two {peer_two.variant}: " + f"{peer_one.is_peer_connected(peer_two)}" + ) + + if peer_two.daemon.is_running: + peer_two.rpc.ping() + self.log.debug( + f"Peer two {peer_two.variant} is connected to peer one {peer_one.variant}: " + f"{peer_two.is_peer_connected(peer_one)}" + ) + def connect_nodes( self, peer_one: Node, diff --git a/tests/test_framework/electrum/base.py b/tests/test_framework/electrum/base.py index c4d160a05..dcf3f91fa 100644 --- a/tests/test_framework/electrum/base.py +++ b/tests/test_framework/electrum/base.py @@ -13,6 +13,9 @@ from test_framework.electrum import ConfigElectrum +# Read response until newline delimiter, using standard buffer size for efficiency +BUFFER_SIZE = 4096 + # pylint: disable=too-few-public-methods class BaseClient: @@ -107,17 +110,25 @@ def request(self, method, params) -> object: self.conn.sendall(request.encode("utf-8") + b"\n") response = b"" - while True: - chunk = self.conn.recv(1) + while b"\n" not in response: + chunk = self.conn.recv(BUFFER_SIZE) if not chunk: break response += chunk - if b"\n" in response: - break response = response.decode("utf-8").strip() + self.log.debug(response) + result = json.loads(response) + + # Check for JSON-RPC error response + if "error" in result and result["error"] is not None: + error = result["error"] + raise ValueError( + f"Electrum RPC error {error.get('code')}: {error.get('message')}" + ) - return json.loads(response) + # Return only the result, not the whole response + return result.get("result") def batch_request(self, calls: List[Tuple[str, List[Any]]]) -> object: """ diff --git a/tests/test_framework/electrum/client.py b/tests/test_framework/electrum/client.py index 4ce966bfb..936218c1a 100644 --- a/tests/test_framework/electrum/client.py +++ b/tests/test_framework/electrum/client.py @@ -24,11 +24,12 @@ def block_header(self, block_hash: str): """ return self.request("blockchain.block.header", [block_hash]) - def get_headers(self, start_height: int, stop_height: int): + def block_headers(self, start_height: int, count: int): """ - Returns all headers in the best known tip. + Return a chunk of block headers from the main chain, starting at `start_height` and + returning at most `count` headers. """ - return self.request("blockchain.block.headers", [start_height, stop_height]) + return self.request("blockchain.block.headers", [start_height, count]) def estimate_fee(self, target: int): """ diff --git a/tests/test_framework/rpc/base.py b/tests/test_framework/rpc/base.py index 76c7ab598..b81f33cc6 100644 --- a/tests/test_framework/rpc/base.py +++ b/tests/test_framework/rpc/base.py @@ -39,7 +39,7 @@ class BaseRPC(ABC): Subclasses should use `perform_request` to implement RPC calls. """ - TIMEOUT: int = 15 # seconds + TIMEOUT: int = 30 # seconds def __init__(self, config: ConfigRPC, log): self._config = config diff --git a/tests/test_framework/util.py b/tests/test_framework/util.py index c2f1f2501..32fb9d835 100644 --- a/tests/test_framework/util.py +++ b/tests/test_framework/util.py @@ -3,6 +3,7 @@ """Utility helpers used by the test framework (paths, ports, TLS helpers).""" import os +import time import random import socket import subprocess @@ -87,3 +88,16 @@ def create_tls_key_cert() -> tuple[str, str]: ) return (pk_path, cert_path) + + +def wait_until(predicate, timeout=60, interval=0.5, error_msg="Condition not met"): + """ + Wait until a predicate returns True or timeout is reached. + """ + start = time.time() + while time.time() - start < timeout: + if predicate(): + return True + time.sleep(interval) + + raise TimeoutError(f"{error_msg} after {timeout} seconds")