diff --git a/hathor/feature_activation/feature_service.py b/hathor/feature_activation/feature_service.py index e07cc7d2d9..24716eb72b 100644 --- a/hathor/feature_activation/feature_service.py +++ b/hathor/feature_activation/feature_service.py @@ -13,7 +13,6 @@ # limitations under the License. from hathor.feature_activation.feature import Feature -from hathor.feature_activation.model.criteria import Criteria from hathor.feature_activation.model.feature_description import FeatureDescription from hathor.feature_activation.model.feature_state import FeatureState from hathor.feature_activation.settings import Settings as FeatureSettings @@ -76,7 +75,10 @@ def _calculate_new_state( ) -> FeatureState: """Returns the new feature state based on the new block, the criteria, and the previous state.""" height = boundary_block.get_height() - criteria = self._get_criteria(feature=feature) + criteria = self._feature_settings.features.get(feature) + + if not criteria: + return FeatureState.DEFINED assert not boundary_block.is_genesis, 'cannot calculate new state for genesis' assert height % self._feature_settings.evaluation_interval == 0, ( @@ -124,15 +126,6 @@ def _calculate_new_state( raise ValueError(f'Unknown previous state: {previous_state}') - def _get_criteria(self, *, feature: Feature) -> Criteria: - """Get the Criteria defined for a specific Feature.""" - criteria = self._feature_settings.features.get(feature) - - if not criteria: - raise ValueError(f"Criteria not defined for feature '{feature}'.") - - return criteria - def get_bits_description(self, *, block: Block) -> dict[Feature, FeatureDescription]: """Returns the criteria definition and feature state for all features at a certain block.""" return { diff --git a/hathor/manager.py b/hathor/manager.py index ec49462462..252841f9ea 100644 --- a/hathor/manager.py +++ b/hathor/manager.py @@ -823,7 +823,7 @@ def _make_block_template(self, parent_block: Block, parent_txs: 'ParentTxs', cur def generate_mining_block(self, timestamp: Optional[int] = None, parent_block_hash: Optional[VertexId] = None, data: bytes = b'', address: Optional[Address] = None, - merge_mined: bool = False) -> Union[Block, MergeMinedBlock]: + merge_mined: bool = False, signal_bits: int = 0) -> Union[Block, MergeMinedBlock]: """ Generates a block ready to be mined. The block includes new issued tokens, parents, and the weight. @@ -840,6 +840,7 @@ def generate_mining_block(self, timestamp: Optional[int] = None, merge_mined=merge_mined, address=address or None, # XXX: because we allow b'' for explicit empty output script data=data, + signal_bits=signal_bits ) return block diff --git a/hathor/mining/block_template.py b/hathor/mining/block_template.py index 292a29b222..007ce89ea2 100644 --- a/hathor/mining/block_template.py +++ b/hathor/mining/block_template.py @@ -48,7 +48,7 @@ def generate_minimaly_valid_block(self) -> BaseTransaction: def generate_mining_block(self, rng: Random, merge_mined: bool = False, address: Optional[bytes] = None, timestamp: Optional[int] = None, data: Optional[bytes] = None, storage: Optional[TransactionStorage] = None, include_metadata: bool = False, - ) -> Union[Block, MergeMinedBlock]: + signal_bits: int = 0) -> Union[Block, MergeMinedBlock]: """ Generates a block by filling the template with the given options and random parents (if multiple choices). Note that if a timestamp is given it will be coerced into the [timestamp_min, timestamp_max] range. @@ -64,7 +64,7 @@ def generate_mining_block(self, rng: Random, merge_mined: bool = False, address: tx_outputs = [TxOutput(self.reward, output_script)] cls: Union[type['Block'], type['MergeMinedBlock']] = MergeMinedBlock if merge_mined else Block block = cls(outputs=tx_outputs, parents=parents, timestamp=block_timestamp, - data=data or b'', storage=storage, weight=self.weight) + data=data or b'', storage=storage, weight=self.weight, signal_bits=signal_bits) if include_metadata: block._metadata = TransactionMetadata(height=self.height, score=self.score) block.get_metadata(use_storage=False) @@ -124,9 +124,10 @@ def choose_random_template(self, rng: Random) -> BlockTemplate: def generate_mining_block(self, rng: Random, merge_mined: bool = False, address: Optional[bytes] = None, timestamp: Optional[int] = None, data: Optional[bytes] = None, storage: Optional[TransactionStorage] = None, include_metadata: bool = False, - ) -> Union[Block, MergeMinedBlock]: + signal_bits: int = 0) -> Union[Block, MergeMinedBlock]: """ Randomly choose a template and use that for generating a block, see BlockTemplate.generate_mining_block""" return self.choose_random_template(rng).generate_mining_block(rng, merge_mined=merge_mined, address=address, timestamp=timestamp, data=data, storage=storage or self.storage, - include_metadata=include_metadata) + include_metadata=include_metadata, + signal_bits=signal_bits) diff --git a/hathor/simulator/miner/geometric_miner.py b/hathor/simulator/miner/geometric_miner.py index 68e8e8c85f..be8d71cc3c 100644 --- a/hathor/simulator/miner/geometric_miner.py +++ b/hathor/simulator/miner/geometric_miner.py @@ -31,13 +31,23 @@ class GeometricMiner(AbstractMiner): """ Simulate block mining with actually solving the block. It is supposed to be used with Simulator class. The mining part is simulated using the geometrical distribution. """ - def __init__(self, manager: 'HathorManager', rng: Random, *, hashpower: float): + def __init__( + self, + manager: 'HathorManager', + rng: Random, + *, + hashpower: float, + signal_bits: Optional[list[int]] = None + ) -> None: """ :param: hashpower: Number of hashes per second + :param: signal_bits: a list of signal_bits to be used in each mined block, in order. If there are more mined + blocks than values provided, 0 is used. """ super().__init__(manager, rng) self._hashpower = hashpower + self._signal_bits = signal_bits or [] self._block: Optional[Block] = None self._blocks_found: int = 0 @@ -58,6 +68,15 @@ def _on_new_tx(self, key: HathorEvents, args: 'EventArguments') -> None: self._block = None self._schedule_next_block() + def _generate_mining_block(self) -> 'Block': + """Generates a block ready to be mined.""" + try: + signal_bits = self._signal_bits.pop(0) + except IndexError: + signal_bits = 0 + + return self._manager.generate_mining_block(signal_bits=signal_bits) + def _schedule_next_block(self): if self._block: self._block.nonce = self._rng.getrandbits(32) @@ -68,7 +87,7 @@ def _schedule_next_block(self): self._block = None if self._manager.can_start_mining(): - block = self._manager.generate_mining_block() + block = self._generate_mining_block() geometric_p = 2**(-block.weight) trials = self._rng.geometric(geometric_p) dt = 1.0 * trials / self._hashpower diff --git a/tests/feature_activation/test_feature_service.py b/tests/feature_activation/test_feature_service.py index ec3d6a1cf9..d99d47e3c0 100644 --- a/tests/feature_activation/test_feature_service.py +++ b/tests/feature_activation/test_feature_service.py @@ -446,10 +446,9 @@ def test_get_state_from_failed(block_mocks: list[Block], tx_storage: Transaction def test_get_state_undefined_feature(block_mocks: list[Block], service: FeatureService) -> None: block = block_mocks[10] - with pytest.raises(ValueError) as e: - service.get_state(block=block, feature=Feature.NOP_FEATURE_1) + result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) - assert str(e.value) == f"Criteria not defined for feature '{Feature.NOP_FEATURE_1}'." + assert result == FeatureState.DEFINED def test_get_bits_description(tx_storage: TransactionStorage) -> None: diff --git a/tests/feature_activation/test_feature_simulation.py b/tests/feature_activation/test_feature_simulation.py index ce34e8463a..ed31c1f63a 100644 --- a/tests/feature_activation/test_feature_simulation.py +++ b/tests/feature_activation/test_feature_simulation.py @@ -24,37 +24,20 @@ from hathor.feature_activation.model.criteria import Criteria from hathor.feature_activation.resources.feature import FeatureResource from hathor.feature_activation.settings import Settings as FeatureSettings -from hathor.simulator.miner import AbstractMiner +from hathor.simulator import FakeConnection from hathor.simulator.trigger import StopAfterNMinedBlocks from tests import unittest from tests.resources.base_resource import StubSite from tests.simulation.base import SimulatorTestCase from tests.utils import HAS_ROCKSDB -_FEATURE_SETTINGS = FeatureSettings( - evaluation_interval=4, - max_signal_bits=4, - default_threshold=3, - features={ - Feature.NOP_FEATURE_1: Criteria( - bit=0, - start_height=20, - timeout_height=60, - activate_on_timeout=True, - version='0.0.0' - ) - } -) - class BaseFeatureSimulationTest(SimulatorTestCase): builder: Builder - def _get_result_after(self, *, n_blocks: int, miner: AbstractMiner, web_client: StubSite) -> dict[str, Any]: - """Returns the feature activation api response after N blocks.""" - trigger = StopAfterNMinedBlocks(miner, quantity=n_blocks) - self.simulator.run(36000, trigger=trigger) - + @staticmethod + def _get_result(web_client: StubSite) -> dict[str, Any]: + """Returns the feature activation api response.""" response = web_client.get('feature') result = response.result.json_value() @@ -76,10 +59,25 @@ def test_feature(self) -> None: manager = artifacts.manager manager.allow_mining_without_peers() + feature_settings = FeatureSettings( + evaluation_interval=4, + max_signal_bits=4, + default_threshold=3, + features={ + Feature.NOP_FEATURE_1: Criteria( + bit=0, + start_height=20, + timeout_height=60, + activate_on_timeout=True, + version='0.0.0' + ) + } + ) + feature_service = artifacts.feature_service - feature_service._feature_settings = _FEATURE_SETTINGS + feature_service._feature_settings = feature_settings feature_resource = FeatureResource( - feature_settings=_FEATURE_SETTINGS, + feature_settings=feature_settings, feature_service=feature_service, tx_storage=artifacts.tx_storage ) @@ -96,7 +94,9 @@ def test_feature(self) -> None: patch.object(feature_service_module, '_get_ancestor_iteratively', get_ancestor_iteratively_mock) ): # at the beginning, the feature is DEFINED: - result = self._get_result_after(n_blocks=10, miner=miner, web_client=web_client) + trigger = StopAfterNMinedBlocks(miner, quantity=10) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) assert result == dict( block_height=10, features=[ @@ -120,7 +120,9 @@ def test_feature(self) -> None: get_state_mock.reset_mock() # at block 19, the feature is DEFINED, just before becoming STARTED: - result = self._get_result_after(n_blocks=9, miner=miner, web_client=web_client) + trigger = StopAfterNMinedBlocks(miner, quantity=9) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) assert result == dict( block_height=19, features=[ @@ -143,7 +145,9 @@ def test_feature(self) -> None: get_state_mock.reset_mock() # at block 20, the feature becomes STARTED: - result = self._get_result_after(n_blocks=1, miner=miner, web_client=web_client) + trigger = StopAfterNMinedBlocks(miner, quantity=1) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) assert result == dict( block_height=20, features=[ @@ -165,7 +169,9 @@ def test_feature(self) -> None: get_state_mock.reset_mock() # at block 39, the feature is STARTED, just before becoming ACTIVE: - result = self._get_result_after(n_blocks=39, miner=miner, web_client=web_client) + trigger = StopAfterNMinedBlocks(miner, quantity=39) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) assert result == dict( block_height=59, features=[ @@ -189,7 +195,9 @@ def test_feature(self) -> None: get_state_mock.reset_mock() # at block 60, the feature becomes ACTIVE, forever: - result = self._get_result_after(n_blocks=1, miner=miner, web_client=web_client) + trigger = StopAfterNMinedBlocks(miner, quantity=1) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) assert result == dict( block_height=60, features=[ @@ -210,6 +218,207 @@ def test_feature(self) -> None: assert get_ancestor_iteratively_mock.call_count == 0 get_state_mock.reset_mock() + def test_reorg(self) -> None: + artifacts = self.simulator.create_artifacts(self.builder) + manager = artifacts.manager + manager.allow_mining_without_peers() + + feature_settings = FeatureSettings( + evaluation_interval=4, + max_signal_bits=4, + default_threshold=3, + features={ + Feature.NOP_FEATURE_1: Criteria( + bit=1, + start_height=4, + timeout_height=100, + activate_on_timeout=False, + version='0.0.0' + ) + } + ) + feature_service = artifacts.feature_service + feature_service._feature_settings = feature_settings + feature_resource = FeatureResource( + feature_settings=feature_settings, + feature_service=feature_service, + tx_storage=artifacts.tx_storage + ) + web_client = StubSite(feature_resource) + + # 4 blocks per evaluation interval, and the genesis is skipped + signal_bits = [ + 0b0000, 0b0000, 0b0000, # 0% acceptance + 0b0000, 0b0000, 0b0010, 0b0000, # 25% acceptance + 0b0000, 0b0010, 0b0010, 0b0000, # 50% acceptance + 0b0010, 0b0000, 0b0010, 0b0010, # 75% acceptance + ] + + miner = self.simulator.create_miner(manager, hashpower=1e6, signal_bits=signal_bits) + miner.start() + + # at the beginning, the feature is DEFINED: + trigger = StopAfterNMinedBlocks(miner, quantity=0) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) + assert result == dict( + block_height=0, + features=[ + dict( + name='NOP_FEATURE_1', + state='DEFINED', + acceptance=None, + threshold=0.75, + start_height=4, + timeout_height=100, + minimum_activation_height=0, + activate_on_timeout=False, + version='0.0.0' + ) + ] + ) + + # at block 4, the feature becomes STARTED with 0% acceptance + trigger = StopAfterNMinedBlocks(miner, quantity=4) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) + assert result == dict( + block_height=4, + features=[ + dict( + name='NOP_FEATURE_1', + state='STARTED', + acceptance=0, + threshold=0.75, + start_height=4, + timeout_height=100, + minimum_activation_height=0, + activate_on_timeout=False, + version='0.0.0' + ) + ] + ) + + # at block 7, acceptance was 25% + trigger = StopAfterNMinedBlocks(miner, quantity=3) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) + assert result == dict( + block_height=7, + features=[ + dict( + name='NOP_FEATURE_1', + state='STARTED', + acceptance=0.25, + threshold=0.75, + start_height=4, + timeout_height=100, + minimum_activation_height=0, + activate_on_timeout=False, + version='0.0.0' + ) + ] + ) + + # at block 11, acceptance was 50% + trigger = StopAfterNMinedBlocks(miner, quantity=4) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) + assert result == dict( + block_height=11, + features=[ + dict( + name='NOP_FEATURE_1', + state='STARTED', + acceptance=0.5, + threshold=0.75, + start_height=4, + timeout_height=100, + minimum_activation_height=0, + activate_on_timeout=False, + version='0.0.0' + ) + ] + ) + + # at block 15, acceptance was 75%, so the feature will be activated in the next block + trigger = StopAfterNMinedBlocks(miner, quantity=4) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) + assert result == dict( + block_height=15, + features=[ + dict( + name='NOP_FEATURE_1', + state='STARTED', + acceptance=0.75, + threshold=0.75, + start_height=4, + timeout_height=100, + minimum_activation_height=0, + activate_on_timeout=False, + version='0.0.0' + ) + ] + ) + + # at block 16, the feature is activated + trigger = StopAfterNMinedBlocks(miner, quantity=1) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) + assert result == dict( + block_height=16, + features=[ + dict( + name='NOP_FEATURE_1', + state='ACTIVE', + acceptance=None, + threshold=0.75, + start_height=4, + timeout_height=100, + minimum_activation_height=0, + activate_on_timeout=False, + version='0.0.0' + ) + ] + ) + + miner.stop() + + # We then create a new manager with a miner that mines one more block (17 vs 16), so its blockchain wins when + # both managers are connected. This causes a reorg and the feature goes back to the STARTED state. + manager2 = self.simulator.create_peer() + manager2.allow_mining_without_peers() + + miner2 = self.simulator.create_miner(manager2, hashpower=1e6) + + miner2.start() + trigger = StopAfterNMinedBlocks(miner2, quantity=17) + self.simulator.run(36000, trigger=trigger) + miner2.stop() + + connection = FakeConnection(manager, manager2) + self.simulator.add_connection(connection) + self.simulator.run(60) + + result = self._get_result(web_client) + assert result == dict( + block_height=17, + features=[ + dict( + name='NOP_FEATURE_1', + state='STARTED', + acceptance=0, + threshold=0.75, + start_height=4, + timeout_height=100, + minimum_activation_height=0, + activate_on_timeout=False, + version='0.0.0' + ) + ] + ) + class BaseMemoryStorageFeatureSimulationTest(BaseFeatureSimulationTest): def setUp(self): @@ -238,10 +447,25 @@ def test_feature_from_existing_storage(self) -> None: manager1 = artifacts1.manager manager1.allow_mining_without_peers() + feature_settings = FeatureSettings( + evaluation_interval=4, + max_signal_bits=4, + default_threshold=3, + features={ + Feature.NOP_FEATURE_1: Criteria( + bit=0, + start_height=20, + timeout_height=60, + activate_on_timeout=True, + version='0.0.0' + ) + } + ) + feature_service = artifacts1.feature_service - feature_service._feature_settings = _FEATURE_SETTINGS + feature_service._feature_settings = feature_settings feature_resource = FeatureResource( - feature_settings=_FEATURE_SETTINGS, + feature_settings=feature_settings, feature_service=feature_service, tx_storage=artifacts1.tx_storage ) @@ -259,7 +483,9 @@ def test_feature_from_existing_storage(self) -> None: ): assert artifacts1.tx_storage.get_vertices_count() == 3 # genesis vertices in the storage - result = self._get_result_after(n_blocks=60, miner=miner, web_client=web_client) + trigger = StopAfterNMinedBlocks(miner, quantity=60) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) assert result == dict( block_height=60, features=[ @@ -295,9 +521,9 @@ def test_feature_from_existing_storage(self) -> None: # new feature_service is created with the same storage generated above feature_service = artifacts2.feature_service - feature_service._feature_settings = _FEATURE_SETTINGS + feature_service._feature_settings = feature_settings feature_resource = FeatureResource( - feature_settings=_FEATURE_SETTINGS, + feature_settings=feature_settings, feature_service=feature_service, tx_storage=artifacts2.tx_storage ) @@ -314,10 +540,7 @@ def test_feature_from_existing_storage(self) -> None: assert artifacts2.tx_storage.get_vertices_count() == 63 self.simulator.run(3600) - response = web_client.get('feature') - result = response.result.json_value() - - del result['block_hash'] # we don't assert the block hash because it's not always the same + result = self._get_result(web_client) assert result == dict( block_height=60,