tests: test_network: intro MockBlockchain. rewrite tests to use it.

interface.py no longer has knowledge about mocking! :P
This commit is contained in:
SomberNight
2025-06-09 18:59:31 +00:00
parent cb1789a59c
commit 02c6e118f0
4 changed files with 91 additions and 67 deletions
+1 -1
View File
@@ -626,7 +626,7 @@ class Blockchain(Logger):
work_in_last_partial_chunk = (height % CHUNK_SIZE + 1) * work_in_single_header work_in_last_partial_chunk = (height % CHUNK_SIZE + 1) * work_in_single_header
return running_total + work_in_last_partial_chunk 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: if header is None:
return False return False
height = header['block_height'] height = header['block_height']
+16 -20
View File
@@ -1052,27 +1052,26 @@ class Interface(Logger):
) )
header = await self.get_block_header(height, mode=ChainResolutionMode.CATCHUP) 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: 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. # note: there is an edge case here that is not handled.
# we might know the blockhash (enough for check_header) but # we might know the blockhash (enough for check_header) but
# not have the header itself. e.g. regtest chain with only genesis. # not have the header itself. e.g. regtest chain with only genesis.
# this situation resolves itself on the next block # this situation resolves itself on the next block
return ChainResolutionMode.CATCHUP, height+1 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: if not can_connect:
self.logger.info(f"can't connect new block: {height=}") self.logger.info(f"can't connect new block: {height=}")
height, header, bad, bad_header = await self._search_headers_backwards(height, header=header) 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) chain = blockchain.check_header(header)
can_connect = blockchain.can_connect(header) if 'mock' not in header else header['mock']['connect'](height) can_connect = blockchain.can_connect(header)
assert chain or can_connect assert chain or can_connect
if can_connect: if can_connect:
height += 1 height += 1
if isinstance(can_connect, Blockchain): # not when mocking self.blockchain = can_connect
self.blockchain = can_connect self.blockchain.save_header(header)
self.blockchain.save_header(header)
return ChainResolutionMode.CATCHUP, height return ChainResolutionMode.CATCHUP, height
good, bad, bad_header = await self._search_headers_binary(height, bad, bad_header, chain) 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 bad == bad_header['block_height']
_assert_header_does_not_check_against_any_chain(bad_header) _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 good = height
while True: while True:
assert 0 <= good < bad, (good, bad) assert 0 <= good < bad, (good, bad)
@@ -1098,9 +1097,9 @@ class Interface(Logger):
await self._maybe_warm_headers_cache( await self._maybe_warm_headers_cache(
from_height=good, to_height=bad, mode=ChainResolutionMode.BINARY) from_height=good, to_height=bad, mode=ChainResolutionMode.BINARY)
header = await self.get_block_header(height, 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: if chain:
self.blockchain = chain if isinstance(chain, Blockchain) else self.blockchain # for mocking self.blockchain = chain
good = height good = height
else: else:
bad = height bad = height
@@ -1108,9 +1107,7 @@ class Interface(Logger):
if good + 1 == bad: if good + 1 == bad:
break break
mock = 'mock' in bad_header and bad_header['mock']['connect'](height) if not self.blockchain.can_connect(bad_header, check_height=False):
real = not mock and self.blockchain.can_connect(bad_header, check_height=False)
if not real and not mock:
raise Exception('unexpected bad header during binary: {}'.format(bad_header)) raise Exception('unexpected bad header during binary: {}'.format(bad_header))
_assert_header_does_not_check_against_any_chain(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 # this is a new fork we don't yet have
height = bad + 1 height = bad + 1
self.logger.info(f"new fork at bad height {bad}") 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 = self.blockchain.fork(bad_header) # type: Blockchain
b = forkfun(bad_header) # type: Blockchain
self.blockchain = b self.blockchain = b
assert b.forkpoint == bad assert b.forkpoint == bad
return ChainResolutionMode.FORK, height return ChainResolutionMode.FORK, height
@@ -1158,8 +1154,8 @@ class Interface(Logger):
height = constants.net.max_checkpoint() height = constants.net.max_checkpoint()
checkp = True checkp = True
header = await self.get_block_header(height, mode=ChainResolutionMode.BACKWARD) 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) chain = blockchain.check_header(header)
can_connect = blockchain.can_connect(header) if 'mock' not in header else header['mock']['connect'](height) can_connect = blockchain.can_connect(header)
if chain or can_connect: if chain or can_connect:
return False return False
if checkp: if checkp:
@@ -1169,7 +1165,7 @@ class Interface(Logger):
bad, bad_header = height, header bad, bad_header = height, header
_assert_header_does_not_check_against_any_chain(bad_header) _assert_header_does_not_check_against_any_chain(bad_header)
with blockchain.blockchains_lock: chains = list(blockchain.blockchains.values()) 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) height = min(local_max + 1, height - 1)
assert height >= 0 assert height >= 0
@@ -1420,7 +1416,7 @@ class Interface(Logger):
def _assert_header_does_not_check_against_any_chain(header: dict) -> None: 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: if chain_bad:
raise Exception('bad_header must not check!') raise Exception('bad_header must not check!')
+3 -3
View File
@@ -224,7 +224,7 @@ class TestBlockchain(ElectrumTestCase):
self.assertEqual(os.path.join(self.data_dir, "blockchain_headers"), chain_l.path()) self.assertEqual(os.path.join(self.data_dir, "blockchain_headers"), chain_l.path())
self.assertEqual(11 * 80, os.stat(chain_l.path()).st_size) self.assertEqual(11 * 80, os.stat(chain_l.path()).st_size)
for b in (chain_u, chain_l): 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['S'])
self._append_header(chain_u, self.HEADERS['T']) 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(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) self.assertEqual(7 * 80, os.stat(chain_u.path()).st_size)
for b in (chain_u, chain_l, chain_z): 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(constants.net.GENESIS, chain_z.get_hash(0))
self.assertEqual(hash_header(self.HEADERS['F']), chain_z.get_hash(5)) 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)) self.assertEqual(hash_header(self.HEADERS['X']), chain_z.get_hash(11))
for b in (chain_u, chain_l, chain_z): 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): def get_chains_that_contain_header_helper(self, header: dict):
height = header['block_height'] height = header['block_height']
+71 -43
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import tempfile import tempfile
import unittest import unittest
from typing import List
from electrum import constants from electrum import constants
from electrum.simple_config import SimpleConfig from electrum.simple_config import SimpleConfig
@@ -16,6 +17,42 @@ from . import ElectrumTestCase
CRM = ChainResolutionMode 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: class MockNetwork:
def __init__(self, config: SimpleConfig): def __init__(self, config: SimpleConfig):
@@ -30,9 +67,6 @@ class MockInterface(Interface):
network = MockNetwork(config) network = MockNetwork(config)
super().__init__(network=network, server=ServerAddr.from_str('mock-server:50000:t')) super().__init__(network=network, server=ServerAddr.from_str('mock-server:50000:t'))
self.q = asyncio.Queue() 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: async def get_block_header(self, height: int, *, mode: ChainResolutionMode) -> dict:
assert self.q.qsize() > 0, (height, mode) assert self.q.qsize() > 0, (height, mode)
@@ -48,10 +82,6 @@ class MockInterface(Interface):
async def _maybe_warm_headers_cache(self, *args, **kwargs): async def _maybe_warm_headers_cache(self, *args, **kwargs):
return return
def set_tip(self, tip: int):
self.tip = tip
self.blockchain._size = self.tip + 1
class TestNetwork(ElectrumTestCase): class TestNetwork(ElectrumTestCase):
@@ -65,6 +95,10 @@ class TestNetwork(ElectrumTestCase):
super().tearDownClass() super().tearDownClass()
constants.BitcoinMainnet.set_as_network() constants.BitcoinMainnet.set_as_network()
def tearDown(self):
blockchain.blockchains = {}
super().tearDown()
async def asyncSetUp(self): async def asyncSetUp(self):
await super().asyncSetUp() await super().asyncSetUp()
self.config = SimpleConfig({'electrum_path': self.electrum_path}) 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. server is on other side of chain split, the last common block is height 6.
""" """
ifa = self.interface ifa = self.interface
ifa.set_tip(12) # FIXME how could the server tip be this high? for local chain, it's ok though. ifa.tip = 12 # FIXME how could the server tip be this high?
blockchain.blockchains = {} ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a", "06a", "07a", "08a", "09a", "10a", "11a", "12a"])
ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) blockchain.blockchains = {"00a": ifa.blockchain}
def mock_connect(height): ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'id': '08b', 'prev_id': '07b'}})
return height == 6 ifa.q.put_nowait({'block_height': 7, 'mock': {CRM.BACKWARD:1, 'id': '07b', 'prev_id': '06a'}})
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, 'id': '02a', 'prev_id': '01a'}})
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, 'id': '04a', 'prev_id': '03a'}})
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, 'id': '05a', 'prev_id': '04a'}})
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, 'id': '06a', 'prev_id': '05a'}})
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) res = await ifa.sync_until(8, next_height=7)
self.assertEqual((CRM.FORK, 8), res) self.assertEqual((CRM.FORK, 8), res)
self.assertEqual(ifa.q.qsize(), 0) 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). client happens to ask for header at height 2 during backward search (which directly builds on top the existing fork).
""" """
ifa = self.interface ifa = self.interface
ifa.set_tip(12) # FIXME how could the server tip be this high? for local chain, it's ok though. ifa.tip = 12 # FIXME how could the server tip be this high?
blockchain.blockchains = {} ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a", "06a", "07a", "08a", "09a", "10a", "11a", "12a"])
ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) blockchain.blockchains = {
def mock_connect(height): "00a": ifa.blockchain,
return height == 2 "01b": MockBlockchain(["00a", "01b"]),
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': 8, 'mock': {CRM.CATCHUP:1, 'id': '08b', 'prev_id': '07b'}})
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': 7, 'mock': {CRM.BACKWARD:1, 'id': '07b', 'prev_id': '06b'}})
ifa.q.put_nowait({'block_height': 4, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: True}}) 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) res = await ifa.sync_until(8, next_height=4)
self.assertEqual((CRM.CATCHUP, 5), res) self.assertEqual((CRM.CATCHUP, 5), res)
self.assertEqual(ifa.q.qsize(), 0) 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 # finds forkpoint during binary, new fork
async def test_chain_false_during_binary(self): async def test_chain_false_during_binary(self):
"""client starts on main chain, has no knowledge of any fork. """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. server is on other side of chain split, the last common block is height 3.
""" """
ifa = self.interface ifa = self.interface
ifa.set_tip(12) # FIXME how could the server tip be this high? for local chain, it's ok though. ifa.tip = 12 # FIXME how could the server tip be this high?
blockchain.blockchains = {} ifa.blockchain = MockBlockchain(["00a", "01a", "02a", "03a", "04a", "05a", "06a", "07a", "08a", "09a", "10a", "11a", "12a"])
ifa.q.put_nowait({'block_height': 8, 'mock': {CRM.CATCHUP:1, 'check': lambda x: False, 'connect': lambda x: False}}) blockchain.blockchains = {"00a": ifa.blockchain}
mock_connect = lambda height: height == 3 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, 'check': lambda x: False, 'connect': mock_connect}}) 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, 'check': lambda x: True, 'connect': mock_connect}}) 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, 'check': lambda x: False, 'fork': self.mock_fork, 'connect': mock_connect}}) 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, 'check': lambda x: True, 'connect': lambda x: True}}) 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, 'check': lambda x: False, 'connect': lambda x: True}}) 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, 'check': lambda x: False, 'connect': lambda x: True}}) 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(8, next_height=6)
self.assertEqual((CRM.CATCHUP, 7), res) self.assertEqual((CRM.CATCHUP, 7), res)
self.assertEqual(ifa.q.qsize(), 0) self.assertEqual(ifa.q.qsize(), 0)