tests: interface: implement toy electrum server
What changed, and why it matters
This commit adds new automated tests for Electrum's network interface code. It builds a small fake Electrum server and a regtest-style test harness so developers can verify that the wallet correctly downloads block headers from a server. There is no change to how the live wallet behaves, and no security fix or vulnerability is present in the diff.
No security action required. This is a test-only change. Reviewers may optionally confirm that the new _blockchain_updated event is only used for test synchronization and does not alter callback ordering or timing in production.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit introduces a toy Electrum server (ServerSession) and a new TestInterface unit-test class. To make the tests deterministic, it adds a REGTEST flag to the test base class, wires regtest constants during setUpClass, and exposes an internal asyncio.Event (_blockchain_updated) in Interface so tests can wait for the blockchain-updated callback. The production logic in interface.py is only modified to set/clear that event around existing callback triggers; no protocol, validation, or network behavior changes.
Changed components
electrum/interface.pytests/__init__.pytests/test_interface.pyInspect captured patch +152 / −4
diff --git a/electrum/interface.py b/electrum/interface.py
index c9f438e..2411a06 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 624a3e3..51decf3 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 84e223e..e84026b 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())
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.