From 8efc2aab5e5098177c21b6f0ae2e49e8c75efcd3 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Thu, 7 Aug 2025 15:52:50 +0000 Subject: [PATCH] tests: interface: implement toy electrum server --- electrum/interface.py | 5 ++ tests/__init__.py | 10 ++- tests/test_interface.py | 141 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 152 insertions(+), 4 deletions(-) diff --git a/electrum/interface.py b/electrum/interface.py index c9f438e47..2411a06b0 100644 --- a/electrum/interface.py +++ b/electrum/interface.py @@ -529,6 +529,7 @@ class Interface(Logger): assert isinstance(server, ServerAddr), f"expected ServerAddr, got {type(server)}" self.ready = network.asyncio_loop.create_future() self.got_disconnected = asyncio.Event() + self._blockchain_updated = asyncio.Event() self.server = server Logger.__init__(self) assert network.config.path @@ -1058,6 +1059,8 @@ class Interface(Logger): self.logger.info(f"new chain tip. {height=}") if blockchain_updated: util.trigger_callback('blockchain_updated') + self._blockchain_updated.set() + self._blockchain_updated.clear() util.trigger_callback('network_updated') await self.network.switch_unwanted_fork_interface() await self.network.switch_lagging_interface() @@ -1100,6 +1103,8 @@ class Interface(Logger): continue # report progress to gui/etc util.trigger_callback('blockchain_updated') + self._blockchain_updated.set() + self._blockchain_updated.clear() util.trigger_callback('network_updated') height += num_headers assert height <= next_height+1, (height, self.tip) diff --git a/tests/__init__.py b/tests/__init__.py index 624a3e3e0..51decf3bb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -28,6 +28,7 @@ class ElectrumTestCase(unittest.IsolatedAsyncioTestCase, Logger): """Base class for our unit tests.""" TESTNET = False + REGTEST = False TEST_ANCHOR_CHANNELS = False # maxDiff = None # for debugging @@ -41,19 +42,22 @@ class ElectrumTestCase(unittest.IsolatedAsyncioTestCase, Logger): @classmethod def setUpClass(cls): super().setUpClass() - if cls.TESTNET: + assert not (cls.REGTEST and cls.TESTNET), "regtest and testnet are mutually exclusive" + if cls.REGTEST: + constants.BitcoinRegtest.set_as_network() + elif cls.TESTNET: constants.BitcoinTestnet.set_as_network() @classmethod def tearDownClass(cls): super().tearDownClass() - if cls.TESTNET: + if cls.TESTNET or cls.REGTEST: constants.BitcoinMainnet.set_as_network() def setUp(self): self._test_lock.acquire() super().setUp() - self.electrum_path = tempfile.mkdtemp() + self.electrum_path = tempfile.mkdtemp(prefix="electrum-unittest-base-") assert util._asyncio_event_loop is None, "global event loop already set?!" async def asyncSetUp(self): diff --git a/tests/test_interface.py b/tests/test_interface.py index 84e223e09..e84026ba0 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -1,4 +1,12 @@ -from electrum.interface import ServerAddr +import asyncio + +import aiorpcx + +from electrum.interface import ServerAddr, Interface, PaddedRSTransport +from electrum import util, blockchain +from electrum.util import OldTaskGroup, bfh +from electrum.logging import Logger +from electrum.simple_config import SimpleConfig from . import ElectrumTestCase @@ -46,3 +54,134 @@ class TestServerAddr(ElectrumTestCase): ServerAddr(host="2400:6180:0:d1::86b:e001", port=50002, protocol="s").to_friendly_name()) self.assertEqual("[2400:6180:0:d1::86b:e001]:50001:t", ServerAddr(host="2400:6180:0:d1::86b:e001", port=50001, protocol="t").to_friendly_name()) + + +class MockNetwork: + + def __init__(self, *, config: SimpleConfig): + self.config = config + self.asyncio_loop = util.get_asyncio_loop() + self.taskgroup = OldTaskGroup() + blockchain.read_blockchains(self.config) + blockchain.init_headers_file_for_best_chain() + self.proxy = None + self.debug = True + self.bhi_lock = asyncio.Lock() + + async def connection_down(self, interface: Interface): + pass + def get_network_timeout_seconds(self, request_type) -> int: + return 10 + def check_interface_against_healthy_spread_of_connected_servers(self, iface_to_check: Interface) -> bool: + return True + def update_fee_estimates(self, *, fee_est: dict[int, int] = None): + pass + async def switch_unwanted_fork_interface(self): + pass + async def switch_lagging_interface(self): + pass + + +# regtest chain: +BLOCK_HEADERS = { + 0: bfh("0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff7f2002000000"), + 1: bfh("0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f186c8dfd970a4545f79916bc1d75c9d00432f57c89209bf3bb115b7612848f509c25f45bffff7f2000000000"), + 2: bfh("00000020686bdfc6a3db73d5d93e8c9663a720a26ecb1ef20eb05af11b36cdbc57c19f7ebf2cbf153013a1c54abaf70e95198fcef2f3059cc6b4d0f7e876808e7d24d11cc825f45bffff7f2000000000"), + 3: bfh("00000020122baa14f3ef54985ae546d1611559e3f487bd2a0f46e8dbb52fbacc9e237972e71019d7feecd9b8596eca9a67032c5f4641b23b5d731dc393e37de7f9c2f299e725f45bffff7f2000000000"), + 4: bfh("00000020f8016f7ef3a17d557afe05d4ea7ab6bde1b2247b7643896c1b63d43a1598b747a3586da94c71753f27c075f57f44faf913c31177a0957bbda42e7699e3a2141aed25f45bffff7f2001000000"), + 5: bfh("000000201d589c6643c1d121d73b0573e5ee58ab575b8fdf16d507e7e915c5fbfbbfd05e7aee1d692d1615c3bdf52c291032144ce9e3b258a473c17c745047f3431ff8e2ee25f45bffff7f2000000000"), + 6: bfh("00000020b833ed46eea01d4c980f59feee44a66aa1162748b6801029565d1466790c405c3a141ce635cbb1cd2b3a4fcdd0a3380517845ba41736c82a79cab535d31128066526f45bffff7f2001000000"), + 7: bfh("00000020abe8e119d1877c9dc0dc502d1a253fb9a67967c57732d2f71ee0280e8381ff0a9690c2fe7c1a4450c74dc908fe94dd96c3b0637d51475e9e06a78e944a0c7fe28126f45bffff7f2000000000"), + 8: bfh("000000202ce41d94eb70e1518bc1f72523f84a903f9705d967481e324876e1f8cf4d3452148be228a4c3f2061bafe7efdfc4a8d5a94759464b9b5c619994d45dfcaf49e1a126f45bffff7f2000000000"), + 9: bfh("00000020552755b6c59f3d51e361d16281842a4e166007799665b5daed86a063dd89857415681cb2d00ff889193f6a68a93f5096aeb2d84ca0af6185a462555822552221a626f45bffff7f2000000000"), + 10: bfh("00000020a13a491cbefc93cd1bb1938f19957e22a134faf14c7dee951c45533e2c750f239dc087fc977b06c24a69c682d1afd1020e6dc1f087571ccec66310a786e1548fab26f45bffff7f2000000000"), + 11: bfh("00000020dbf3a9b55dfefbaf8b6e43a89cf833fa2e208bbc0c1c5d76c0d71b9e4a65337803b243756c25053253aeda309604363460a3911015929e68705bd89dff6fe064b026f45bffff7f2002000000"), + 12: bfh("000000203d0932b3b0c78eccb39a595a28ae4a7c966388648d7783fd1305ec8d40d4fe5fd67cb902a7d807cee7676cb543feec3e053aa824d5dfb528d5b94f9760313d9db726f45bffff7f2001000000"), +} + +_active_server_sessions = set() +def _get_active_server_session() -> 'ServerSession': + assert 1 == len(_active_server_sessions), len(_active_server_sessions) + return list(_active_server_sessions)[0] + +class ServerSession(aiorpcx.RPCSession, Logger): + + def __init__(self, *args, **kwargs): + aiorpcx.RPCSession.__init__(self, *args, **kwargs) + Logger.__init__(self) + self.logger.debug(f'connection from {self.remote_address()}') + self.cur_height = 6 # type: int # chain tip + _active_server_sessions.add(self) + + async def connection_lost(self): + await super().connection_lost() + self.logger.debug(f'{self.remote_address()} disconnected') + _active_server_sessions.discard(self) + + async def handle_request(self, request): + handlers = { + 'server.version': self._handle_server_version, + 'blockchain.estimatefee': self._handle_estimatefee, + 'blockchain.headers.subscribe': self._handle_headers_subscribe, + 'blockchain.block.header': self._handle_block_header, + 'blockchain.block.headers': self._handle_block_headers, + 'server.ping': self._handle_ping, + } + handler = handlers.get(request.method) + coro = aiorpcx.handler_invocation(handler, request)() + return await coro + + async def _handle_server_version(self, client_name='', protocol_version=None): + return ['best_server_impl/0.1', '1.4'] + + async def _handle_estimatefee(self, number, mode=None): + return 1000 + + async def _handle_headers_subscribe(self): + return {'hex': BLOCK_HEADERS[self.cur_height].hex(), 'height': self.cur_height} + + async def _handle_block_header(self, height): + return BLOCK_HEADERS[height].hex() + + async def _handle_block_headers(self, start_height, count): + assert start_height <= self.cur_height, (start_height, self.cur_height) + last_height = min(start_height+count-1, self.cur_height) # [start_height, last_height] + count = last_height - start_height + 1 + headers = b"".join(BLOCK_HEADERS[idx] for idx in range(start_height, last_height+1)) + return {'hex': headers.hex(), 'count': count, 'max': 2016} + + async def _handle_ping(self): + return None + + +class TestInterface(ElectrumTestCase): + REGTEST = True + + def setUp(self): + super().setUp() + self.config = SimpleConfig({'electrum_path': self.electrum_path}) + self._orig_WAIT_FOR_BUFFER_GROWTH_SECONDS = PaddedRSTransport.WAIT_FOR_BUFFER_GROWTH_SECONDS + PaddedRSTransport.WAIT_FOR_BUFFER_GROWTH_SECONDS = 0 + + def tearDown(self): + PaddedRSTransport.WAIT_FOR_BUFFER_GROWTH_SECONDS = self._orig_WAIT_FOR_BUFFER_GROWTH_SECONDS + super().tearDown() + + async def asyncSetUp(self): + await super().asyncSetUp() + self._server: asyncio.base_events.Server = await aiorpcx.serve_rs(ServerSession, "127.0.0.1") + server_socket_addr = self._server.sockets[0].getsockname() + self._server_port = server_socket_addr[1] + self.network = MockNetwork(config=self.config) + + async def asyncTearDown(self): + self._server.close() + await super().asyncTearDown() + + async def test_client_syncs_headers_to_tip(self): + interface = Interface(network=self.network, server=ServerAddr(host="127.0.0.1", port=self._server_port, protocol="t")) + self.network.interface = interface + await util.wait_for2(interface.ready, 5) + await interface._blockchain_updated.wait() + self.assertEqual(_get_active_server_session().cur_height, interface.tip) + self.assertFalse(interface.got_disconnected.is_set())