From b1804c186224f1cdcca1703f5e3577c2c4a4b871 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:30:43 -0300 Subject: [PATCH 1/5] feat(integration): use `wait_until` for interactive condition checks Replaced manual waiting logic with the `wait_until` utility in the getblock, gettxout, getblockhash, getblockcount, getbestblockhash and reorg_chain tests and in the function wait_for_peers_connections in the test_framework/init.py. The `wait_until` function periodically evaluates a given predicate and returns `True` when the condition is met. If the condition is not met within the timeout, it raises an exception. --- tests/floresta-cli/getbestblockhash.py | 16 +++---- tests/floresta-cli/getblock.py | 15 +++---- tests/floresta-cli/getblockcount.py | 17 +++---- tests/floresta-cli/getblockhash.py | 17 +++---- tests/floresta-cli/gettxout.py | 29 ++++++------ tests/florestad/reorg_chain.py | 21 +++------ tests/test_framework/__init__.py | 61 +++++++++++++------------- tests/test_framework/rpc/base.py | 2 +- tests/test_framework/util.py | 14 ++++++ 9 files changed, 90 insertions(+), 102 deletions(-) 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/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/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") From 5a14b9fb121b0f44b0b330f60bd28b8c21c62777 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Tue, 26 May 2026 15:36:11 -0300 Subject: [PATCH 2/5] refactor(integration): Improve buffer reading and error handling Socket reads are now optimized with a 4KB buffer instead of byte-by-byte reads, JSON-RPC error validation raises ValueError on server errors, and only the result field is returned to simplify client usage. --- tests/example/electrum.py | 4 ++-- tests/florestad/tls.py | 4 +--- tests/test_framework/electrum/base.py | 21 ++++++++++++++++----- 3 files changed, 19 insertions(+), 10 deletions(-) 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/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/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: """ From 36c2c16d14de8b77c477683d1067fc25399b19de Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Tue, 26 May 2026 15:40:20 -0300 Subject: [PATCH 3/5] test(integration): Add block header endpoint tests Tests the blockchain.block.header endpoint by validating header consistency between Electrum and RPC responses, ensuring proper error handling for out-of-range heights, and including random height validation for comprehensive coverage. --- pyproject.toml | 1 + tests/electrum/blockchain_block_header.py | 45 +++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/electrum/blockchain_block_header.py 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 From 5abaac3db5cc370bda43fdae630de7df608de713 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Tue, 26 May 2026 18:46:18 -0300 Subject: [PATCH 4/5] fix(electrum): change response key from 'hex' to 'headers' for clarity --- crates/floresta-electrum/src/electrum_protocol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, }) } From d0724fdb7d8b656bfb4da3333083f31621b89ac1 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Tue, 26 May 2026 18:51:19 -0300 Subject: [PATCH 5/5] test(integration): add integration test for blockchain.block.headers endpoint Add new test file blockchain_block_headers.py with full test coverage for the blockchain.block.headers endpoint. The tests cover out-of-range requests, invalid parameters, valid requests across different ranges, and validation of the max count limit. Compares Electrum headers against RPC responses to ensure consistency validation. Additionally, rename the ElectrumClient method from get_headers() to block_headers() --- tests/electrum/blockchain_block_headers.py | 72 ++++++++++++++++++++++ tests/test_framework/electrum/client.py | 7 ++- 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 tests/electrum/blockchain_block_headers.py 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/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): """