From 9016c36df06ff9bed482bbd15a614f3a853e9c7e Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 9 Jun 2025 15:46:02 +0000 Subject: [PATCH 1/7] tests: test_network.py: rm test_fork_conflict, no longer applicable Just prior to this commit, test_fork_conflict and test_fork_noconflict were essentially identical copies. The only diff was that test_fork_conflict set the global blockchain.blockchains, but this was not even affecting its behaviour anymore. Originally when this test was added, we had the concept of chain fork conflicting with each other: we could not handle three-way chain-splits. As in, there could only be a single fork forking away from the main chain at any given height. see https://github.com/spesmilo/electrum/commit/7221fb3231a736dc5d3ebd5ed5e0723c9b8203f2 However, this restriction was removed and generalised later: https://github.com/spesmilo/electrum/commit/141ff99580192c920bc6bb7f6bbc9d35449daea8 After which the "test_fork_conflict" test did not make sense anymore. --- tests/test_network.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/tests/test_network.py b/tests/test_network.py index 4ff5132c9..0ea825b69 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -67,7 +67,7 @@ class TestNetwork(ElectrumTestCase): self.config = SimpleConfig({'electrum_path': self.electrum_path}) self.interface = MockInterface(self.config) - async def test_fork_noconflict(self): + async def test_fork(self): blockchain.blockchains = {} self.interface.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) def mock_connect(height): @@ -82,21 +82,6 @@ class TestNetwork(ElectrumTestCase): self.assertEqual((CRM.FORK, 8), res) self.assertEqual(self.interface.q.qsize(), 0) - async def test_fork_conflict(self): - blockchain.blockchains = {7: {'check': lambda bad_header: False}} - self.interface.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) - def mock_connect(height): - return height == 6 - self.interface.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1,'check': lambda x: False, 'connect': mock_connect, 'fork': self.mock_fork}}) - self.interface.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1,'check':lambda x: True, 'connect': lambda x: False}}) - self.interface.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1,'check':lambda x: True, 'connect': lambda x: True}}) - self.interface.q.put_nowait({'block_height': 5, 'mock': {CRM.BINARY:1,'check':lambda x: True, 'connect': lambda x: True}}) - self.interface.q.put_nowait({'block_height': 6, 'mock': {CRM.BINARY:1,'check':lambda x: True, 'connect': lambda x: True}}) - ifa = self.interface - res = await ifa.sync_until(8, next_height=7) - self.assertEqual((CRM.FORK, 8), res) - self.assertEqual(self.interface.q.qsize(), 0) - async def test_can_connect_during_backward(self): blockchain.blockchains = {} self.interface.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) From ffb12b283341dac45a0ef579627b6f034e9c87b7 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 9 Jun 2025 17:22:49 +0000 Subject: [PATCH 2/7] tests: test_network: shorten lines --- tests/test_network.py | 50 +++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/tests/test_network.py b/tests/test_network.py index 0ea825b69..8b2042cc0 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -68,33 +68,33 @@ class TestNetwork(ElectrumTestCase): self.interface = MockInterface(self.config) async def test_fork(self): + ifa = self.interface blockchain.blockchains = {} - self.interface.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) + ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) def mock_connect(height): return height == 6 - self.interface.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1,'check': lambda x: False, 'connect': mock_connect, 'fork': self.mock_fork}}) - self.interface.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1,'check':lambda x: True, 'connect': lambda x: False}}) - self.interface.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1,'check':lambda x: True, 'connect': lambda x: True}}) - self.interface.q.put_nowait({'block_height': 5, 'mock': {CRM.BINARY:1,'check':lambda x: True, 'connect': lambda x: True}}) - self.interface.q.put_nowait({'block_height': 6, 'mock': {CRM.BINARY:1,'check':lambda x: True, 'connect': lambda x: True}}) - ifa = self.interface + ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1,'check': lambda x: False, 'connect': mock_connect, 'fork': self.mock_fork}}) + ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1,'check':lambda x: True, 'connect': lambda x: False}}) + ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1,'check':lambda x: True, 'connect': lambda x: True}}) + ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.BINARY:1,'check':lambda x: True, 'connect': lambda x: True}}) + ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.BINARY:1,'check':lambda x: True, 'connect': lambda x: True}}) res = await ifa.sync_until(8, next_height=7) self.assertEqual((CRM.FORK, 8), res) - self.assertEqual(self.interface.q.qsize(), 0) + self.assertEqual(ifa.q.qsize(), 0) async def test_can_connect_during_backward(self): + ifa = self.interface blockchain.blockchains = {} - self.interface.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) + ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) def mock_connect(height): return height == 2 - self.interface.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect, 'fork': self.mock_fork}}) - self.interface.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect, 'fork': self.mock_fork}}) - self.interface.q.put_nowait({'block_height': 3, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) - self.interface.q.put_nowait({'block_height': 4, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) - ifa = self.interface + ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect, 'fork': self.mock_fork}}) + ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect, 'fork': self.mock_fork}}) + ifa.q.put_nowait({'block_height': 3, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) + ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) res = await ifa.sync_until(8, next_height=4) self.assertEqual((CRM.CATCHUP, 5), res) - self.assertEqual(self.interface.q.qsize(), 0) + self.assertEqual(ifa.q.qsize(), 0) def mock_fork(self, bad_header): forkpoint = bad_header['block_height'] @@ -103,19 +103,19 @@ class TestNetwork(ElectrumTestCase): return b async def test_chain_false_during_binary(self): - blockchain.blockchains = {} - self.interface.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) - mock_connect = lambda height: height == 3 - self.interface.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect}}) - self.interface.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'check': lambda x: True, 'connect': mock_connect}}) - self.interface.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1, 'check': lambda x: False, 'fork': self.mock_fork, 'connect': mock_connect}}) - self.interface.q.put_nowait({'block_height': 3, 'mock': {CRM.BINARY:1, 'check': lambda x: True, 'connect': lambda x: True}}) - self.interface.q.put_nowait({'block_height': 5, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) - self.interface.q.put_nowait({'block_height': 6, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) ifa = self.interface + blockchain.blockchains = {} + ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) + mock_connect = lambda height: height == 3 + ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect}}) + ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'check': lambda x: True, 'connect': mock_connect}}) + ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1, 'check': lambda x: False, 'fork': self.mock_fork, 'connect': mock_connect}}) + ifa.q.put_nowait({'block_height': 3, 'mock': {CRM.BINARY:1, 'check': lambda x: True, 'connect': lambda x: True}}) + ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) + ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) res = await ifa.sync_until(8, next_height=6) self.assertEqual((CRM.CATCHUP, 7), res) - self.assertEqual(self.interface.q.qsize(), 0) + self.assertEqual(ifa.q.qsize(), 0) if __name__ == "__main__": From 09e412baf8d69259753f178204bca10ad5964b1b Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 9 Jun 2025 17:32:54 +0000 Subject: [PATCH 3/7] tests: enable verbose stderr logging note: print() statements and stderr logging don't have a consistent printing order. Either can buffer log lines and flush them later, and the buffers are independent. --- tests/__init__.py | 2 +- tests/test_network.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 4fac041ca..624a3e3e0 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -19,7 +19,7 @@ from electrum.logging import Logger FAST_TESTS = False -electrum.logging._configure_stderr_logging() +electrum.logging._configure_stderr_logging(verbosity="*") electrum.util.AS_LIB_USER_I_WANT_TO_MANAGE_MY_OWN_ASYNCIO_LOOP = True diff --git a/tests/test_network.py b/tests/test_network.py index 8b2042cc0..682f4535b 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -38,7 +38,7 @@ class MockInterface(Interface): async def get_block_header(self, height: int, *, mode: ChainResolutionMode) -> dict: assert self.q.qsize() > 0, (height, mode) item = await self.q.get() - print("step with height", height, item) + self.logger.debug(f"step with {height=}. {item=}") assert item['block_height'] == height, (item['block_height'], height) assert mode in item['mock'], (mode, item) return item From cb1789a59c521a743bcd2e6ff94104d3f0785a0b Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 9 Jun 2025 17:57:36 +0000 Subject: [PATCH 4/7] tests: test_network: try to explain test cases no functional changes --- electrum/interface.py | 2 +- tests/test_network.py | 28 ++++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/electrum/interface.py b/electrum/interface.py index 5515161dc..b2c2010da 100644 --- a/electrum/interface.py +++ b/electrum/interface.py @@ -1100,7 +1100,7 @@ class Interface(Logger): header = await self.get_block_header(height, mode=ChainResolutionMode.BINARY) chain = blockchain.check_header(header) if 'mock' not in header else header['mock']['check'](header) if chain: - self.blockchain = chain if isinstance(chain, Blockchain) else self.blockchain + self.blockchain = chain if isinstance(chain, Blockchain) else self.blockchain # for mocking good = height else: bad = height diff --git a/tests/test_network.py b/tests/test_network.py index 682f4535b..e3482a9a2 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -32,8 +32,7 @@ class MockInterface(Interface): self.q = asyncio.Queue() self.blockchain = blockchain.Blockchain(config=self.config, forkpoint=0, parent=None, forkpoint_hash=constants.net.GENESIS, prev_hash=None) - self.tip = 12 - self.blockchain._size = self.tip + 1 + self.set_tip(0) async def get_block_header(self, height: int, *, mode: ChainResolutionMode) -> dict: assert self.q.qsize() > 0, (height, mode) @@ -49,6 +48,10 @@ class MockInterface(Interface): async def _maybe_warm_headers_cache(self, *args, **kwargs): return + def set_tip(self, tip: int): + self.tip = tip + self.blockchain._size = self.tip + 1 + class TestNetwork(ElectrumTestCase): @@ -67,8 +70,13 @@ class TestNetwork(ElectrumTestCase): self.config = SimpleConfig({'electrum_path': self.electrum_path}) self.interface = MockInterface(self.config) + # finds forkpoint during binary, new fork async def test_fork(self): + """client starts on main chain, has no knowledge of any fork. + server is on other side of chain split, the last common block is height 6. + """ ifa = self.interface + ifa.set_tip(12) # FIXME how could the server tip be this high? for local chain, it's ok though. blockchain.blockchains = {} ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) def mock_connect(height): @@ -82,14 +90,20 @@ class TestNetwork(ElectrumTestCase): self.assertEqual((CRM.FORK, 8), res) self.assertEqual(ifa.q.qsize(), 0) + # finds forkpoint during backwards, existing fork async def test_can_connect_during_backward(self): + """client starts on main chain. client already knows about another fork, which has local height 1. + server is on that fork but has more blocks. + client happens to ask for header at height 2 during backward search (which directly builds on top the existing fork). + """ ifa = self.interface + ifa.set_tip(12) # FIXME how could the server tip be this high? for local chain, it's ok though. blockchain.blockchains = {} ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) def mock_connect(height): return height == 2 - ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect, 'fork': self.mock_fork}}) - ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect, 'fork': self.mock_fork}}) + ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect}}) + ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect}}) ifa.q.put_nowait({'block_height': 3, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) res = await ifa.sync_until(8, next_height=4) @@ -98,12 +112,18 @@ class TestNetwork(ElectrumTestCase): def mock_fork(self, bad_header): forkpoint = bad_header['block_height'] + self.interface.logger.debug(f"mock_fork() called with {forkpoint=}") b = blockchain.Blockchain(config=self.config, forkpoint=forkpoint, parent=None, forkpoint_hash=sha256(str(forkpoint)).hex(), prev_hash=sha256(str(forkpoint-1)).hex()) return b + # finds forkpoint during binary, new fork async def test_chain_false_during_binary(self): + """client starts on main chain, has no knowledge of any fork. + server is on other side of chain split, the last common block is height 3. + """ ifa = self.interface + ifa.set_tip(12) # FIXME how could the server tip be this high? for local chain, it's ok though. blockchain.blockchains = {} ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) mock_connect = lambda height: height == 3 From 02c6e118f06969d2172cb25562a008709b67aefa Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 9 Jun 2025 18:59:31 +0000 Subject: [PATCH 5/7] tests: test_network: intro MockBlockchain. rewrite tests to use it. interface.py no longer has knowledge about mocking! :P --- electrum/blockchain.py | 2 +- electrum/interface.py | 36 ++++++------- tests/test_blockchain.py | 6 +-- tests/test_network.py | 114 ++++++++++++++++++++++++--------------- 4 files changed, 91 insertions(+), 67 deletions(-) diff --git a/electrum/blockchain.py b/electrum/blockchain.py index c65dba1a7..5d101a796 100644 --- a/electrum/blockchain.py +++ b/electrum/blockchain.py @@ -626,7 +626,7 @@ class Blockchain(Logger): work_in_last_partial_chunk = (height % CHUNK_SIZE + 1) * work_in_single_header return running_total + work_in_last_partial_chunk - def can_connect(self, header: dict, check_height: bool=True) -> bool: + def can_connect(self, header: dict, *, check_height: bool = True) -> bool: if header is None: return False height = header['block_height'] diff --git a/electrum/interface.py b/electrum/interface.py index b2c2010da..829c05403 100644 --- a/electrum/interface.py +++ b/electrum/interface.py @@ -1052,27 +1052,26 @@ class Interface(Logger): ) header = await self.get_block_header(height, mode=ChainResolutionMode.CATCHUP) - chain = blockchain.check_header(header) if 'mock' not in header else header['mock']['check'](header) + chain = blockchain.check_header(header) if chain: - self.blockchain = chain if isinstance(chain, Blockchain) else self.blockchain + self.blockchain = chain # note: there is an edge case here that is not handled. # we might know the blockhash (enough for check_header) but # not have the header itself. e.g. regtest chain with only genesis. # this situation resolves itself on the next block return ChainResolutionMode.CATCHUP, height+1 - can_connect = blockchain.can_connect(header) if 'mock' not in header else header['mock']['connect'](height) + can_connect = blockchain.can_connect(header) if not can_connect: self.logger.info(f"can't connect new block: {height=}") height, header, bad, bad_header = await self._search_headers_backwards(height, header=header) - chain = blockchain.check_header(header) if 'mock' not in header else header['mock']['check'](header) - can_connect = blockchain.can_connect(header) if 'mock' not in header else header['mock']['connect'](height) + chain = blockchain.check_header(header) + can_connect = blockchain.can_connect(header) assert chain or can_connect if can_connect: height += 1 - if isinstance(can_connect, Blockchain): # not when mocking - self.blockchain = can_connect - self.blockchain.save_header(header) + self.blockchain = can_connect + self.blockchain.save_header(header) return ChainResolutionMode.CATCHUP, height good, bad, bad_header = await self._search_headers_binary(height, bad, bad_header, chain) @@ -1088,7 +1087,7 @@ class Interface(Logger): assert bad == bad_header['block_height'] _assert_header_does_not_check_against_any_chain(bad_header) - self.blockchain = chain if isinstance(chain, Blockchain) else self.blockchain + self.blockchain = chain good = height while True: assert 0 <= good < bad, (good, bad) @@ -1098,9 +1097,9 @@ class Interface(Logger): await self._maybe_warm_headers_cache( from_height=good, to_height=bad, mode=ChainResolutionMode.BINARY) header = await self.get_block_header(height, mode=ChainResolutionMode.BINARY) - chain = blockchain.check_header(header) if 'mock' not in header else header['mock']['check'](header) + chain = blockchain.check_header(header) if chain: - self.blockchain = chain if isinstance(chain, Blockchain) else self.blockchain # for mocking + self.blockchain = chain good = height else: bad = height @@ -1108,9 +1107,7 @@ class Interface(Logger): if good + 1 == bad: break - mock = 'mock' in bad_header and bad_header['mock']['connect'](height) - real = not mock and self.blockchain.can_connect(bad_header, check_height=False) - if not real and not mock: + if not self.blockchain.can_connect(bad_header, check_height=False): raise Exception('unexpected bad header during binary: {}'.format(bad_header)) _assert_header_does_not_check_against_any_chain(bad_header) @@ -1139,8 +1136,7 @@ class Interface(Logger): # this is a new fork we don't yet have height = bad + 1 self.logger.info(f"new fork at bad height {bad}") - forkfun = self.blockchain.fork if 'mock' not in bad_header else bad_header['mock']['fork'] - b = forkfun(bad_header) # type: Blockchain + b = self.blockchain.fork(bad_header) # type: Blockchain self.blockchain = b assert b.forkpoint == bad return ChainResolutionMode.FORK, height @@ -1158,8 +1154,8 @@ class Interface(Logger): height = constants.net.max_checkpoint() checkp = True header = await self.get_block_header(height, mode=ChainResolutionMode.BACKWARD) - chain = blockchain.check_header(header) if 'mock' not in header else header['mock']['check'](header) - can_connect = blockchain.can_connect(header) if 'mock' not in header else header['mock']['connect'](height) + chain = blockchain.check_header(header) + can_connect = blockchain.can_connect(header) if chain or can_connect: return False if checkp: @@ -1169,7 +1165,7 @@ class Interface(Logger): bad, bad_header = height, header _assert_header_does_not_check_against_any_chain(bad_header) with blockchain.blockchains_lock: chains = list(blockchain.blockchains.values()) - local_max = max([0] + [x.height() for x in chains]) if 'mock' not in header else float('inf') + local_max = max([0] + [x.height() for x in chains]) height = min(local_max + 1, height - 1) assert height >= 0 @@ -1420,7 +1416,7 @@ class Interface(Logger): def _assert_header_does_not_check_against_any_chain(header: dict) -> None: - chain_bad = blockchain.check_header(header) if 'mock' not in header else header['mock']['check'](header) + chain_bad = blockchain.check_header(header) if chain_bad: raise Exception('bad_header must not check!') diff --git a/tests/test_blockchain.py b/tests/test_blockchain.py index 3a52ac654..aaf6ca306 100644 --- a/tests/test_blockchain.py +++ b/tests/test_blockchain.py @@ -224,7 +224,7 @@ class TestBlockchain(ElectrumTestCase): self.assertEqual(os.path.join(self.data_dir, "blockchain_headers"), chain_l.path()) self.assertEqual(11 * 80, os.stat(chain_l.path()).st_size) for b in (chain_u, chain_l): - self.assertTrue(all([b.can_connect(b.read_header(i), False) for i in range(b.height())])) + self.assertTrue(all([b.can_connect(b.read_header(i), check_height=False) for i in range(b.height())])) self._append_header(chain_u, self.HEADERS['S']) self._append_header(chain_u, self.HEADERS['T']) @@ -259,7 +259,7 @@ class TestBlockchain(ElectrumTestCase): self.assertEqual(os.path.join(self.data_dir, "forks", "fork2_6_5c400c7966145d56291080b6482716a16aa644eefe590f984c1da0ee46ed33b8_aff81830e28e01ef7d23277c56779a6b93f251a2d50dcc09d7c87d119e1e8ab"), chain_u.path()) self.assertEqual(7 * 80, os.stat(chain_u.path()).st_size) for b in (chain_u, chain_l, chain_z): - self.assertTrue(all([b.can_connect(b.read_header(i), False) for i in range(b.height())])) + self.assertTrue(all([b.can_connect(b.read_header(i), check_height=False) for i in range(b.height())])) self.assertEqual(constants.net.GENESIS, chain_z.get_hash(0)) self.assertEqual(hash_header(self.HEADERS['F']), chain_z.get_hash(5)) @@ -334,7 +334,7 @@ class TestBlockchain(ElectrumTestCase): self.assertEqual(hash_header(self.HEADERS['X']), chain_z.get_hash(11)) for b in (chain_u, chain_l, chain_z): - self.assertTrue(all([b.can_connect(b.read_header(i), False) for i in range(b.height())])) + self.assertTrue(all([b.can_connect(b.read_header(i), check_height=False) for i in range(b.height())])) def get_chains_that_contain_header_helper(self, header: dict): height = header['block_height'] diff --git a/tests/test_network.py b/tests/test_network.py index e3482a9a2..33bf7b85c 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,6 +1,7 @@ import asyncio import tempfile import unittest +from typing import List from electrum import constants from electrum.simple_config import SimpleConfig @@ -16,6 +17,42 @@ from . import ElectrumTestCase CRM = ChainResolutionMode +class MockBlockchain: + + def __init__(self, headers: List[str]): + self._headers = headers + self.forkpoint = len(headers) + + def height(self) -> int: + return len(self._headers) - 1 + + def save_header(self, header: dict) -> None: + assert header['block_height'] == self.height()+1, f"new {header['block_height']=}, cur {self.height()=}" + self._headers.append(header['mock']['id']) + + def check_header(self, header: dict) -> bool: + return header['mock']['id'] in self._headers + + def can_connect(self, header: dict, *, check_height: bool = True) -> bool: + height = header['block_height'] + if check_height and self.height() != height - 1: + return False + if self.check_header(header): + return True + return header['mock']['prev_id'] in self._headers + + def fork(parent, header: dict) -> 'MockBlockchain': + if not parent.can_connect(header, check_height=False): + raise Exception("forking header does not connect to parent chain") + forkpoint = header.get('block_height') + self = MockBlockchain(parent._headers[:forkpoint]) + self.save_header(header) + chain_id = header['mock']['id'] + with blockchain.blockchains_lock: + blockchain.blockchains[chain_id] = self + return self + + class MockNetwork: def __init__(self, config: SimpleConfig): @@ -30,9 +67,6 @@ class MockInterface(Interface): network = MockNetwork(config) super().__init__(network=network, server=ServerAddr.from_str('mock-server:50000:t')) self.q = asyncio.Queue() - self.blockchain = blockchain.Blockchain(config=self.config, forkpoint=0, - parent=None, forkpoint_hash=constants.net.GENESIS, prev_hash=None) - self.set_tip(0) async def get_block_header(self, height: int, *, mode: ChainResolutionMode) -> dict: assert self.q.qsize() > 0, (height, mode) @@ -48,10 +82,6 @@ class MockInterface(Interface): async def _maybe_warm_headers_cache(self, *args, **kwargs): return - def set_tip(self, tip: int): - self.tip = tip - self.blockchain._size = self.tip + 1 - class TestNetwork(ElectrumTestCase): @@ -65,6 +95,10 @@ class TestNetwork(ElectrumTestCase): super().tearDownClass() constants.BitcoinMainnet.set_as_network() + def tearDown(self): + blockchain.blockchains = {} + super().tearDown() + async def asyncSetUp(self): await super().asyncSetUp() self.config = SimpleConfig({'electrum_path': self.electrum_path}) @@ -76,16 +110,15 @@ class TestNetwork(ElectrumTestCase): server is on other side of chain split, the last common block is height 6. """ ifa = self.interface - ifa.set_tip(12) # FIXME how could the server tip be this high? for local chain, it's ok though. - blockchain.blockchains = {} - ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) - def mock_connect(height): - return height == 6 - ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1,'check': lambda x: False, 'connect': mock_connect, 'fork': self.mock_fork}}) - ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1,'check':lambda x: True, 'connect': lambda x: False}}) - ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1,'check':lambda x: True, 'connect': lambda x: True}}) - ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.BINARY:1,'check':lambda x: True, 'connect': lambda x: True}}) - ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.BINARY:1,'check':lambda x: True, 'connect': lambda x: True}}) + ifa.tip = 12 # FIXME how could the server tip be this high? + ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a", "06a", "07a", "08a", "09a", "10a", "11a", "12a"]) + blockchain.blockchains = {"00a": ifa.blockchain} + ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'id': '08b', 'prev_id': '07b'}}) + ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'id': '07b', 'prev_id': '06a'}}) + ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'id': '02a', 'prev_id': '01a'}}) + ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1, 'id': '04a', 'prev_id': '03a'}}) + ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.BINARY:1, 'id': '05a', 'prev_id': '04a'}}) + ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.BINARY:1, 'id': '06a', 'prev_id': '05a'}}) res = await ifa.sync_until(8, next_height=7) self.assertEqual((CRM.FORK, 8), res) self.assertEqual(ifa.q.qsize(), 0) @@ -97,42 +130,37 @@ class TestNetwork(ElectrumTestCase): client happens to ask for header at height 2 during backward search (which directly builds on top the existing fork). """ ifa = self.interface - ifa.set_tip(12) # FIXME how could the server tip be this high? for local chain, it's ok though. - blockchain.blockchains = {} - ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) - def mock_connect(height): - return height == 2 - ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect}}) - ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect}}) - ifa.q.put_nowait({'block_height': 3, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) - ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) + ifa.tip = 12 # FIXME how could the server tip be this high? + ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a", "06a", "07a", "08a", "09a", "10a", "11a", "12a"]) + blockchain.blockchains = { + "00a": ifa.blockchain, + "01b": MockBlockchain(["00a", "01b"]), + } + ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'id': '08b', 'prev_id': '07b'}}) + ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'id': '07b', 'prev_id': '06b'}}) + ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'id': '02b', 'prev_id': '01b'}}) + ifa.q.put_nowait({'block_height': 3, 'mock': {CRM.CATCHUP:1, 'id': '03b', 'prev_id': '02b'}}) + ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.CATCHUP:1, 'id': '04b', 'prev_id': '03b'}}) res = await ifa.sync_until(8, next_height=4) self.assertEqual((CRM.CATCHUP, 5), res) self.assertEqual(ifa.q.qsize(), 0) - def mock_fork(self, bad_header): - forkpoint = bad_header['block_height'] - self.interface.logger.debug(f"mock_fork() called with {forkpoint=}") - b = blockchain.Blockchain(config=self.config, forkpoint=forkpoint, parent=None, - forkpoint_hash=sha256(str(forkpoint)).hex(), prev_hash=sha256(str(forkpoint-1)).hex()) - return b - # finds forkpoint during binary, new fork async def test_chain_false_during_binary(self): """client starts on main chain, has no knowledge of any fork. server is on other side of chain split, the last common block is height 3. """ ifa = self.interface - ifa.set_tip(12) # FIXME how could the server tip be this high? for local chain, it's ok though. - blockchain.blockchains = {} - ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) - mock_connect = lambda height: height == 3 - ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'check': lambda x: False, 'connect': mock_connect}}) - ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'check': lambda x: True, 'connect': mock_connect}}) - ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1, 'check': lambda x: False, 'fork': self.mock_fork, 'connect': mock_connect}}) - ifa.q.put_nowait({'block_height': 3, 'mock': {CRM.BINARY:1, 'check': lambda x: True, 'connect': lambda x: True}}) - ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) - ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) + ifa.tip = 12 # FIXME how could the server tip be this high? + ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a", "06a", "07a", "08a", "09a", "10a", "11a", "12a"]) + blockchain.blockchains = {"00a": ifa.blockchain} + ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'id': '08b', 'prev_id': '07b'}}) + ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'id': '07b', 'prev_id': '06b'}}) + ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'id': '02a', 'prev_id': '01a'}}) + ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1, 'id': '04b', 'prev_id': '03a'}}) + ifa.q.put_nowait({'block_height': 3, 'mock': {CRM.BINARY:1, 'id': '03a', 'prev_id': '02a'}}) + ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.CATCHUP:1, 'id': '05b', 'prev_id': '04b'}}) + ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.CATCHUP:1, 'id': '06b', 'prev_id': '05b'}}) res = await ifa.sync_until(8, next_height=6) self.assertEqual((CRM.CATCHUP, 7), res) self.assertEqual(ifa.q.qsize(), 0) From eb69b6b516b76f29dc990820acd3f48158d11758 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 9 Jun 2025 19:33:16 +0000 Subject: [PATCH 6/7] interface: _search_headers_backwards: start at small delta - interface.tip is the server's tip. - consider scenario: - client has chain len 800_000, is up to date - client goes offline - suddenly there is a short reorg e.g. blocks 799_998, 799_999, 800_000 are reorged - client was offline for long time, finally comes back online again - server tip is 1_000_000, tip_header does not connect to client's local chain - PREVIOUSLY before commit, client would start backwards search - first it asks for header 800_001, which does not connect - then client asks for header ~600k, which checks - client will do long binary search to find the forkpoint - AFTER commit, client starts backwards search - first it asks for header 800_001, which does not connect - then client asks for header 799_999, etc - that is, previously, on average, client did a short backwards search, followed by a long binary search - now, on average, client does a longer backwards search, followed by a shorter binary search - this works much nicer with the headers_cache (- and thomasv said the old behaviour was not intentional) --- electrum/interface.py | 6 +++--- tests/test_network.py | 38 ++++++++++++++++++++------------------ 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/electrum/interface.py b/electrum/interface.py index 829c05403..8cb494f3c 100644 --- a/electrum/interface.py +++ b/electrum/interface.py @@ -1172,11 +1172,11 @@ class Interface(Logger): await self._maybe_warm_headers_cache( from_height=max(0, height-10), to_height=height, mode=ChainResolutionMode.BACKWARD) + delta = 2 while await iterate(): bad, bad_header = height, header - delta = self.tip - height # FIXME why compared to tip? would be easier to cache if delta started at 1 - assert delta > 0, delta - height = self.tip - 2 * delta + height -= delta + delta *= 2 _assert_header_does_not_check_against_any_chain(bad_header) self.logger.info(f"exiting backward mode at {height}") diff --git a/tests/test_network.py b/tests/test_network.py index 33bf7b85c..0aeaa061d 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -110,14 +110,14 @@ class TestNetwork(ElectrumTestCase): server is on other side of chain split, the last common block is height 6. """ ifa = self.interface - ifa.tip = 12 # FIXME how could the server tip be this high? + ifa.tip = 8 ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a", "06a", "07a", "08a", "09a", "10a", "11a", "12a"]) - blockchain.blockchains = {"00a": ifa.blockchain} + blockchain.blockchains = { + "00a": ifa.blockchain, + } ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'id': '08b', 'prev_id': '07b'}}) ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'id': '07b', 'prev_id': '06a'}}) - ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'id': '02a', 'prev_id': '01a'}}) - ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1, 'id': '04a', 'prev_id': '03a'}}) - ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.BINARY:1, 'id': '05a', 'prev_id': '04a'}}) + ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.BACKWARD:1, 'id': '05a', 'prev_id': '04a'}}) ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.BINARY:1, 'id': '06a', 'prev_id': '05a'}}) res = await ifa.sync_until(8, next_height=7) self.assertEqual((CRM.FORK, 8), res) @@ -125,24 +125,23 @@ class TestNetwork(ElectrumTestCase): # finds forkpoint during backwards, existing fork async def test_can_connect_during_backward(self): - """client starts on main chain. client already knows about another fork, which has local height 1. + """client starts on main chain. client already knows about another fork, which has local height 4. server is on that fork but has more blocks. - client happens to ask for header at height 2 during backward search (which directly builds on top the existing fork). + client happens to ask for header at height 5 during backward search (which directly builds on top the existing fork). """ ifa = self.interface - ifa.tip = 12 # FIXME how could the server tip be this high? + ifa.tip = 8 ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a", "06a", "07a", "08a", "09a", "10a", "11a", "12a"]) blockchain.blockchains = { "00a": ifa.blockchain, - "01b": MockBlockchain(["00a", "01b"]), + "03b": MockBlockchain(["00a", "01a", "02a", "03b", "04b"]), } ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'id': '08b', 'prev_id': '07b'}}) ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'id': '07b', 'prev_id': '06b'}}) - ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'id': '02b', 'prev_id': '01b'}}) - ifa.q.put_nowait({'block_height': 3, 'mock': {CRM.CATCHUP:1, 'id': '03b', 'prev_id': '02b'}}) - ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.CATCHUP:1, 'id': '04b', 'prev_id': '03b'}}) - res = await ifa.sync_until(8, next_height=4) - self.assertEqual((CRM.CATCHUP, 5), res) + ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.BACKWARD:1, 'id': '05b', 'prev_id': '04b'}}) + ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.CATCHUP:1, 'id': '06b', 'prev_id': '05b'}}) + res = await ifa.sync_until(8, next_height=6) + self.assertEqual((CRM.CATCHUP, 7), res) self.assertEqual(ifa.q.qsize(), 0) # finds forkpoint during binary, new fork @@ -151,14 +150,17 @@ class TestNetwork(ElectrumTestCase): server is on other side of chain split, the last common block is height 3. """ ifa = self.interface - ifa.tip = 12 # FIXME how could the server tip be this high? + ifa.tip = 8 ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a", "06a", "07a", "08a", "09a", "10a", "11a", "12a"]) - blockchain.blockchains = {"00a": ifa.blockchain} + blockchain.blockchains = { + "00a": ifa.blockchain, + } ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'id': '08b', 'prev_id': '07b'}}) ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'id': '07b', 'prev_id': '06b'}}) - ifa.q.put_nowait({'block_height': 2, 'mock': {CRM.BACKWARD:1, 'id': '02a', 'prev_id': '01a'}}) - ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1, 'id': '04b', 'prev_id': '03a'}}) + ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.BACKWARD:1, 'id': '05b', 'prev_id': '04b'}}) + ifa.q.put_nowait({'block_height': 1, 'mock': {CRM.BACKWARD:1, 'id': '01a', 'prev_id': '00a'}}) ifa.q.put_nowait({'block_height': 3, 'mock': {CRM.BINARY:1, 'id': '03a', 'prev_id': '02a'}}) + ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1, 'id': '04b', 'prev_id': '03a'}}) ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.CATCHUP:1, 'id': '05b', 'prev_id': '04b'}}) ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.CATCHUP:1, 'id': '06b', 'prev_id': '05b'}}) res = await ifa.sync_until(8, next_height=6) From c2e8188568f856d8c3d283a0054b4b5c53481887 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 9 Jun 2025 20:20:47 +0000 Subject: [PATCH 7/7] tests: test_network: add more header chain resolution test cases --- electrum/interface.py | 2 +- tests/test_network.py | 114 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/electrum/interface.py b/electrum/interface.py index 8cb494f3c..5e572ab0c 100644 --- a/electrum/interface.py +++ b/electrum/interface.py @@ -1111,7 +1111,7 @@ class Interface(Logger): raise Exception('unexpected bad header during binary: {}'.format(bad_header)) _assert_header_does_not_check_against_any_chain(bad_header) - self.logger.info(f"binary search exited. good {good}, bad {bad}") + self.logger.info(f"binary search exited. good {good}, bad {bad}. {chain=}") return good, bad, bad_header async def _resolve_potential_chain_fork_given_forkpoint( diff --git a/tests/test_network.py b/tests/test_network.py index 0aeaa061d..5668c5612 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -71,7 +71,7 @@ class MockInterface(Interface): async def get_block_header(self, height: int, *, mode: ChainResolutionMode) -> dict: assert self.q.qsize() > 0, (height, mode) item = await self.q.get() - self.logger.debug(f"step with {height=}. {item=}") + self.logger.debug(f"step with {height=}. {mode=}. will get {item=}") assert item['block_height'] == height, (item['block_height'], height) assert mode in item['mock'], (mode, item) return item @@ -83,7 +83,7 @@ class MockInterface(Interface): return -class TestNetwork(ElectrumTestCase): +class TestHeaderChainResolution(ElectrumTestCase): @classmethod def setUpClass(cls): @@ -104,10 +104,75 @@ class TestNetwork(ElectrumTestCase): self.config = SimpleConfig({'electrum_path': self.electrum_path}) self.interface = MockInterface(self.config) - # finds forkpoint during binary, new fork + async def test_catchup_one_block_behind(self): + """Single chain, but client is behind. The client's height is 5, server is on block 6. + - first missing block found during *catchup* phase + """ + ifa = self.interface + ifa.tip = 6 + ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a"]) + blockchain.blockchains = { + "00a": ifa.blockchain, + } + ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.CATCHUP:1, 'id': '06a', 'prev_id': '05a'}}) + res = await ifa.sync_until(ifa.tip) + self.assertEqual((CRM.CATCHUP, 7), res) + self.assertEqual(ifa.q.qsize(), 0) + self.assertEqual(len(blockchain.blockchains), 1) + + async def test_catchup_already_up_to_date(self): + """Single chain, local chain tip already matches server tip.""" + ifa = self.interface + ifa.tip = 5 + ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a"]) + blockchain.blockchains = { + "00a": ifa.blockchain, + } + ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.CATCHUP:1, 'id': '05a', 'prev_id': '04a'}}) + res = await ifa.sync_until(ifa.tip) + self.assertEqual((CRM.CATCHUP, 6), res) + self.assertEqual(ifa.q.qsize(), 0) + self.assertEqual(len(blockchain.blockchains), 1) + + async def test_catchup_client_ahead_of_lagging_server(self): + """Single chain, server is lagging.""" + ifa = self.interface + ifa.tip = 3 + ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a"]) + blockchain.blockchains = { + "00a": ifa.blockchain, + } + ifa.q.put_nowait({'block_height': 3, 'mock': {CRM.CATCHUP:1, 'id': '03a', 'prev_id': '02a'}}) + res = await ifa.sync_until(ifa.tip) + self.assertEqual((CRM.CATCHUP, 4), res) + self.assertEqual(ifa.q.qsize(), 0) + self.assertEqual(len(blockchain.blockchains), 1) + + async def test_catchup_fast_forward(self): + """Single chain, but client is behind. The client's height is 5, server is already on block 12. + - first missing block found during *backward* phase + """ + ifa = self.interface + ifa.tip = 12 + ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a"]) + blockchain.blockchains = { + "00a": ifa.blockchain, + } + ifa.q.put_nowait({'block_height': 12, 'mock': {CRM.CATCHUP:1, 'id': '12a', 'prev_id': '11a'}}) + ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.BACKWARD:1, 'id': '06a', 'prev_id': '05a'}}) + ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.CATCHUP: 1, 'id': '07a', 'prev_id': '06a'}}) + ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP: 1, 'id': '08a', 'prev_id': '07a'}}) + ifa.q.put_nowait({'block_height': 9, 'mock': {CRM.CATCHUP: 1, 'id': '09a', 'prev_id': '08a'}}) + res = await ifa.sync_until(ifa.tip, next_height=9) + self.assertEqual((CRM.CATCHUP, 10), res) + self.assertEqual(ifa.q.qsize(), 0) + self.assertEqual(len(blockchain.blockchains), 1) + async def test_fork(self): """client starts on main chain, has no knowledge of any fork. server is on other side of chain split, the last common block is height 6. + - first missing block found during *binary* phase + - is *new* fork """ ifa = self.interface ifa.tip = 8 @@ -119,15 +184,16 @@ class TestNetwork(ElectrumTestCase): ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'id': '07b', 'prev_id': '06a'}}) ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.BACKWARD:1, 'id': '05a', 'prev_id': '04a'}}) ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.BINARY:1, 'id': '06a', 'prev_id': '05a'}}) - res = await ifa.sync_until(8, next_height=7) + res = await ifa.sync_until(ifa.tip, next_height=7) self.assertEqual((CRM.FORK, 8), res) self.assertEqual(ifa.q.qsize(), 0) + self.assertEqual(len(blockchain.blockchains), 2) - # finds forkpoint during backwards, existing fork async def test_can_connect_during_backward(self): """client starts on main chain. client already knows about another fork, which has local height 4. server is on that fork but has more blocks. - client happens to ask for header at height 5 during backward search (which directly builds on top the existing fork). + - first missing block found during *backward* phase + - is *existing* fork """ ifa = self.interface ifa.tip = 8 @@ -140,14 +206,16 @@ class TestNetwork(ElectrumTestCase): ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'id': '07b', 'prev_id': '06b'}}) ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.BACKWARD:1, 'id': '05b', 'prev_id': '04b'}}) ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.CATCHUP:1, 'id': '06b', 'prev_id': '05b'}}) - res = await ifa.sync_until(8, next_height=6) + res = await ifa.sync_until(ifa.tip, next_height=6) self.assertEqual((CRM.CATCHUP, 7), res) self.assertEqual(ifa.q.qsize(), 0) + self.assertEqual(len(blockchain.blockchains), 2) - # finds forkpoint during binary, new fork async def test_chain_false_during_binary(self): """client starts on main chain, has no knowledge of any fork. server is on other side of chain split, the last common block is height 3. + - first missing block found during *binary* phase + - is *new* fork """ ifa = self.interface ifa.tip = 8 @@ -163,9 +231,37 @@ class TestNetwork(ElectrumTestCase): ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.BINARY:1, 'id': '04b', 'prev_id': '03a'}}) ifa.q.put_nowait({'block_height': 5, 'mock': {CRM.CATCHUP:1, 'id': '05b', 'prev_id': '04b'}}) ifa.q.put_nowait({'block_height': 6, 'mock': {CRM.CATCHUP:1, 'id': '06b', 'prev_id': '05b'}}) - res = await ifa.sync_until(8, next_height=6) + res = await ifa.sync_until(ifa.tip, next_height=6) self.assertEqual((CRM.CATCHUP, 7), res) self.assertEqual(ifa.q.qsize(), 0) + self.assertEqual(len(blockchain.blockchains), 2) + + async def test_chain_true_during_binary(self): + """client starts on main chain. client already knows about another fork, which has local height 10. + server is on that fork but has more blocks. + - first missing block found during *binary* phase + - is *existing* fork + """ + ifa = self.interface + ifa.tip = 20 + ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a", "06a", "07a", "08a", "09a", "10a", "11a", "12a", "13a", "14a"]) + blockchain.blockchains = { + "00a": ifa.blockchain, + "07b": MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a", "06a", "07b", "08b", "09b", "10b"]), + } + ifa.q.put_nowait({'block_height': 20, 'mock': {CRM.CATCHUP:1, 'id': '20b', 'prev_id': '19b'}}) + ifa.q.put_nowait({'block_height': 15, 'mock': {CRM.BACKWARD:1, 'id': '15b', 'prev_id': '14b'}}) + ifa.q.put_nowait({'block_height': 13, 'mock': {CRM.BACKWARD:1, 'id': '13b', 'prev_id': '12b'}}) + ifa.q.put_nowait({'block_height': 9, 'mock': {CRM.BACKWARD:1, 'id': '09b', 'prev_id': '08b'}}) + ifa.q.put_nowait({'block_height': 11, 'mock': {CRM.BINARY:1, 'id': '11b', 'prev_id': '10b'}}) + ifa.q.put_nowait({'block_height': 10, 'mock': {CRM.BINARY:1, 'id': '10b', 'prev_id': '09b'}}) + ifa.q.put_nowait({'block_height': 11, 'mock': {CRM.CATCHUP:1, 'id': '11b', 'prev_id': '10b'}}) + ifa.q.put_nowait({'block_height': 12, 'mock': {CRM.CATCHUP:1, 'id': '12b', 'prev_id': '11b'}}) + ifa.q.put_nowait({'block_height': 13, 'mock': {CRM.CATCHUP:1, 'id': '13b', 'prev_id': '12b'}}) + res = await ifa.sync_until(ifa.tip, next_height=13) + self.assertEqual((CRM.CATCHUP, 14), res) + self.assertEqual(ifa.q.qsize(), 0) + self.assertEqual(len(blockchain.blockchains), 2) if __name__ == "__main__":