diff --git a/hathor/feature_activation/feature_service.py b/hathor/feature_activation/feature_service.py new file mode 100644 index 000000000..bc1bba5af --- /dev/null +++ b/hathor/feature_activation/feature_service.py @@ -0,0 +1,164 @@ +# Copyright 2023 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# 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 +from hathor.transaction import Block + + +class FeatureService: + __slots__ = ('_settings',) + + def __init__(self, *, settings: Settings) -> None: + self._settings = settings + + def is_feature_active(self, *, block: Block, feature: Feature) -> bool: + """Returns whether a Feature is active at a certain block.""" + state = self.get_state(block=block, feature=feature) + + return state == FeatureState.ACTIVE + + def get_state(self, *, block: Block, feature: Feature) -> FeatureState: + """Returns the state of a feature at a certain block.""" + + # per definition, the genesis block is in the DEFINED state for all features + if block.is_genesis: + return FeatureState.DEFINED + + # All blocks within the same evaluation interval have the same state, that is, the state is only defined for + # the block in each interval boundary. Therefore, we get the state of the previous boundary. + height = block.get_height() + offset_to_boundary = height % self._settings.evaluation_interval + offset_to_previous_boundary = offset_to_boundary or self._settings.evaluation_interval + previous_boundary_height = height - offset_to_previous_boundary + previous_boundary_block = _get_ancestor_at_height(block=block, height=previous_boundary_height) + previous_state = self.get_state(block=previous_boundary_block, feature=feature) + + if offset_to_boundary != 0: + return previous_state + + return self._calculate_new_state( + boundary_block=block, + feature=feature, + previous_state=previous_state + ) + + def _calculate_new_state( + self, + *, + boundary_block: Block, + feature: Feature, + previous_state: FeatureState + ) -> FeatureState: + """Returns the new feature state based on the new block, the criteria, and the previous state.""" + height = boundary_block.get_height() + assert height % self._settings.evaluation_interval == 0, ( + 'cannot calculate new state for a non-boundary block' + ) + criteria = self._get_criteria(feature=feature) + + if previous_state is FeatureState.DEFINED: + if height >= criteria.start_height: + return FeatureState.STARTED + + return FeatureState.DEFINED + + if previous_state is FeatureState.STARTED: + if height >= criteria.timeout_height and not criteria.activate_on_timeout: + return FeatureState.FAILED + + if ( + height >= criteria.timeout_height + and criteria.activate_on_timeout + and height >= criteria.minimum_activation_height + ): + return FeatureState.ACTIVE + + count = self.get_bit_count(boundary_block=boundary_block, bit=criteria.bit) + threshold = criteria.threshold if criteria.threshold is not None else self._settings.default_threshold + + if ( + height < criteria.timeout_height + and count >= threshold + and height >= criteria.minimum_activation_height + ): + return FeatureState.ACTIVE + + return FeatureState.STARTED + + if previous_state is FeatureState.ACTIVE: + return FeatureState.ACTIVE + + if previous_state is FeatureState.FAILED: + return FeatureState.FAILED + + 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._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 { + feature: FeatureDescription( + criteria=criteria, + state=self.get_state(block=block, feature=feature) + ) + for feature, criteria in self._settings.features.items() + } + + def get_bit_count(self, *, boundary_block: Block, bit: int) -> int: + """Returns the count of blocks with this bit enabled in the previous evaluation interval.""" + assert not boundary_block.is_genesis, 'cannot calculate bit count for genesis' + assert boundary_block.get_height() % self._settings.evaluation_interval == 0, ( + 'cannot calculate bit count for a non-boundary block' + ) + count = 0 + block = boundary_block + + # TODO: We can implement this as O(1) instead of O(evaluation_interval) + # by persisting the count in block metadata incrementally + for _ in range(self._settings.evaluation_interval): + block = block.get_block_parent() + feature_bits = block.get_feature_activation_bits() + bit_is_active = (feature_bits >> bit) & 1 + + if bit_is_active: + count += 1 + + return count + + +def _get_ancestor_at_height(*, block: Block, height: int) -> Block: + """Given a block, returns its ancestor at a specific height.""" + # TODO: there may be more optimized ways of doing this using the height index, + # but what if we're not in the best blockchain? + assert height < block.get_height(), ( + f"ancestor height must be lower than the block's height: {height} >= {block.get_height()}" + ) + + ancestor = block + while ancestor.get_height() > height: + ancestor = ancestor.get_block_parent() + + return ancestor diff --git a/hathor/feature_activation/model/feature_description.py b/hathor/feature_activation/model/feature_description.py new file mode 100644 index 000000000..a7f461c21 --- /dev/null +++ b/hathor/feature_activation/model/feature_description.py @@ -0,0 +1,24 @@ +# Copyright 2023 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import NamedTuple + +from hathor.feature_activation.model.criteria import Criteria +from hathor.feature_activation.model.feature_state import FeatureState + + +class FeatureDescription(NamedTuple): + """Represents all information related to one feature, that is, its criteria and state.""" + criteria: Criteria + state: FeatureState diff --git a/hathor/transaction/block.py b/hathor/transaction/block.py index 199e446a0..e2531c9af 100644 --- a/hathor/transaction/block.py +++ b/hathor/transaction/block.py @@ -349,3 +349,22 @@ def verify(self, reject_locked_reward: bool = True) -> None: self.verify_parents() self.verify_height() + + def get_height(self) -> int: + """Returns the block's height.""" + return self.get_metadata().height + + def get_feature_activation_bits(self) -> int: + """Returns the feature activation bits from the signal bits.""" + assert self.signal_bits <= 0xFF, 'signal_bits must be one byte at most' + + bitmask = self._get_feature_activation_bitmask() + + return self.signal_bits & bitmask + + @classmethod + def _get_feature_activation_bitmask(cls) -> int: + """Returns the bitmask that gets feature activation bits from signal bits.""" + bitmask = (1 << settings.FEATURE_ACTIVATION.max_signal_bits) - 1 + + return bitmask diff --git a/tests/feature_activation/test_feature_service.py b/tests/feature_activation/test_feature_service.py new file mode 100644 index 000000000..849fa8b04 --- /dev/null +++ b/tests/feature_activation/test_feature_service.py @@ -0,0 +1,468 @@ +# Copyright 2023 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import Mock, patch + +import pytest + +from hathor.feature_activation import feature_service +from hathor.feature_activation.feature import Feature +from hathor.feature_activation.feature_service import FeatureService +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 +from hathor.transaction import Block + + +@pytest.fixture +def block_mocks() -> list[Block]: + mocks: list[Block] = [] + feature_activation_bits = [ + 0b0110, # 0: boundary block + 0b0010, + 0b0110, + 0b0010, + + 0b0011, # 4: boundary block + 0b0011, + 0b0011, + 0b0001, + + 0b0000, # 8: boundary block + 0b0000, + 0b0000, + 0b0000, + + 0b0000, # 12: boundary block + 0b0000, + 0b0000, + 0b0000, + + 0b0000, # 16: boundary block + 0b0000, + 0b0000, + 0b0000, + + 0b0000, # 20: boundary block + 0b0000, + ] + + for i, bits in enumerate(feature_activation_bits): + mock = Mock(spec_set=Block) + mocks.append(mock) + + mock.is_genesis = i == 0 + mock.get_height = Mock(return_value=i) + mock.get_block_parent = Mock(return_value=mocks[i - 1]) + mock.get_feature_activation_bits = Mock(return_value=bits) + + return mocks + + +@pytest.fixture +def service() -> FeatureService: + settings = Settings( + evaluation_interval=4, + default_threshold=3 + ) + service = FeatureService(settings=settings) + + return service + + +def test_get_state_genesis(block_mocks: list[Block], service: FeatureService) -> None: + block = block_mocks[0] + result = service.get_state(block=block, feature=Mock()) + + assert result == FeatureState.DEFINED + + +@pytest.mark.parametrize('block_height', [0, 1, 2, 3]) +def test_get_state_first_interval(block_mocks: list[Block], service: FeatureService, block_height: int) -> None: + block = block_mocks[block_height] + result = service.get_state(block=block, feature=Mock()) + + assert result == FeatureState.DEFINED + + +@pytest.mark.parametrize('block_height', [4, 5, 6, 7]) +@pytest.mark.parametrize( + ['start_height', 'expected_state'], + [ + (0, FeatureState.STARTED), + (4, FeatureState.STARTED), + (8, FeatureState.DEFINED) + ] +) +def test_get_state_from_defined( + block_mocks: list[Block], + block_height: int, + start_height: int, + expected_state: FeatureState +) -> None: + settings = Settings.construct( + evaluation_interval=4, + features={ + Feature.NOP_FEATURE_1: Criteria.construct( + bit=Mock(), + start_height=start_height, + timeout_height=Mock(), + version=Mock() + ) + } + ) + service = FeatureService(settings=settings) + block = block_mocks[block_height] + + result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) + + assert result == expected_state + + +@pytest.mark.parametrize('block_height', [8, 9, 10, 11, 12, 13]) +@pytest.mark.parametrize('timeout_height', [4, 8]) +def test_get_state_from_started_to_failed(block_mocks: list[Block], block_height: int, timeout_height: int) -> None: + settings = Settings.construct( + evaluation_interval=4, + features={ + Feature.NOP_FEATURE_1: Criteria.construct( + bit=Mock(), + start_height=0, + timeout_height=timeout_height, + activate_on_timeout=False, + version=Mock() + ) + } + ) + service = FeatureService(settings=settings) + block = block_mocks[block_height] + + result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) + + assert result == FeatureState.FAILED + + +@pytest.mark.parametrize('block_height', [8, 9, 10, 11, 12, 13]) +@pytest.mark.parametrize('timeout_height', [4, 8]) +@pytest.mark.parametrize('minimum_activation_height', [0, 4, 8]) +def test_get_state_from_started_to_active_on_timeout( + block_mocks: list[Block], + block_height: int, + timeout_height: int, + minimum_activation_height: int +) -> None: + settings = Settings.construct( + evaluation_interval=4, + features={ + Feature.NOP_FEATURE_1: Criteria.construct( + bit=Mock(), + start_height=0, + timeout_height=timeout_height, + activate_on_timeout=True, + minimum_activation_height=minimum_activation_height, + version=Mock() + ) + } + ) + service = FeatureService(settings=settings) + block = block_mocks[block_height] + + result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) + + assert result == FeatureState.ACTIVE + + +@pytest.mark.parametrize('block_height', [8, 9, 10, 11, 12, 13]) +@pytest.mark.parametrize('minimum_activation_height', [0, 4, 8]) +@pytest.mark.parametrize('default_threshold', [0, 1, 2, 3]) +def test_get_state_from_started_to_active_on_default_threshold( + block_mocks: list[Block], + block_height: int, + minimum_activation_height: int, + default_threshold: int +) -> None: + settings = Settings.construct( + evaluation_interval=4, + default_threshold=default_threshold, + features={ + Feature.NOP_FEATURE_1: Criteria.construct( + bit=1, + start_height=0, + timeout_height=400, + threshold=None, + minimum_activation_height=minimum_activation_height, + version=Mock() + ) + } + ) + service = FeatureService(settings=settings) + block = block_mocks[block_height] + + result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) + + assert result == FeatureState.ACTIVE + + +@pytest.mark.parametrize('block_height', [8, 9, 10, 11, 12, 13]) +@pytest.mark.parametrize('minimum_activation_height', [0, 4, 8]) +@pytest.mark.parametrize('custom_threshold', [0, 1, 2, 3]) +def test_get_state_from_started_to_active_on_custom_threshold( + block_mocks: list[Block], + block_height: int, + minimum_activation_height: int, + custom_threshold: int +) -> None: + settings = Settings.construct( + evaluation_interval=4, + features={ + Feature.NOP_FEATURE_1: Criteria.construct( + bit=1, + start_height=0, + timeout_height=400, + threshold=custom_threshold, + minimum_activation_height=minimum_activation_height, + version=Mock() + ) + } + ) + service = FeatureService(settings=settings) + block = block_mocks[block_height] + + result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) + + assert result == FeatureState.ACTIVE + + +@pytest.mark.parametrize('block_height', [8, 9, 10, 11]) +@pytest.mark.parametrize( + ['activate_on_timeout', 'timeout_height', 'minimum_activation_height'], + [ + (False, 12, 0), + (True, 4, 12), + (True, 8, 12), + ] +) +def test_get_state_from_started_to_started( + block_mocks: list[Block], + block_height: int, + activate_on_timeout: bool, + timeout_height: int, + minimum_activation_height: int +) -> None: + settings = Settings.construct( + evaluation_interval=4, + features={ + Feature.NOP_FEATURE_1: Criteria.construct( + bit=3, + start_height=0, + timeout_height=timeout_height, + activate_on_timeout=activate_on_timeout, + minimum_activation_height=minimum_activation_height, + version=Mock() + ) + } + ) + service = FeatureService(settings=settings) + block = block_mocks[block_height] + + result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) + + assert result == FeatureState.STARTED + + +@pytest.mark.parametrize('block_height', [12, 13, 14, 15]) +def test_get_state_from_active(block_mocks: list[Block], block_height: int) -> None: + settings = Settings.construct( + evaluation_interval=4, + features={ + Feature.NOP_FEATURE_1: Criteria.construct( + bit=Mock(), + start_height=0, + timeout_height=4, + activate_on_timeout=True, + version=Mock() + ) + } + ) + service = FeatureService(settings=settings) + block = block_mocks[block_height] + + result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) + + assert result == FeatureState.ACTIVE + + +@pytest.mark.parametrize('block_height', [12, 13, 14, 15]) +def test_is_feature_active(block_mocks: list[Block], block_height: int) -> None: + settings = Settings.construct( + evaluation_interval=4, + features={ + Feature.NOP_FEATURE_1: Criteria.construct( + bit=Mock(), + start_height=0, + timeout_height=4, + activate_on_timeout=True, + version=Mock() + ) + } + ) + service = FeatureService(settings=settings) + block = block_mocks[block_height] + + result = service.is_feature_active(block=block, feature=Feature.NOP_FEATURE_1) + + assert result is True + + +@pytest.mark.parametrize('block_height', [12, 13, 14, 15]) +def test_get_state_from_failed(block_mocks: list[Block], block_height: int) -> None: + settings = Settings.construct( + evaluation_interval=4, + features={ + Feature.NOP_FEATURE_1: Criteria.construct( + bit=Mock(), + start_height=0, + timeout_height=4, + version=Mock() + ) + } + ) + service = FeatureService(settings=settings) + block = block_mocks[block_height] + + result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) + + assert result == FeatureState.FAILED + + +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) + + assert str(e.value) == f"Criteria not defined for feature '{Feature.NOP_FEATURE_1}'." + + +def test_get_bits_description(): + criteria_mock_1 = Criteria.construct() + criteria_mock_2 = Criteria.construct() + settings = Settings.construct( + features={ + Feature.NOP_FEATURE_1: criteria_mock_1, + Feature.NOP_FEATURE_2: criteria_mock_2 + } + ) + service = FeatureService(settings=settings) + + def get_state(self: FeatureService, *, block: Block, feature: Feature) -> FeatureState: + states = { + Feature.NOP_FEATURE_1: FeatureState.STARTED, + Feature.NOP_FEATURE_2: FeatureState.FAILED + } + return states[feature] + + with patch('hathor.feature_activation.feature_service.FeatureService.get_state', get_state): + result = service.get_bits_description(block=Mock()) + + expected = { + Feature.NOP_FEATURE_1: FeatureDescription(criteria_mock_1, FeatureState.STARTED), + Feature.NOP_FEATURE_2: FeatureDescription(criteria_mock_2, FeatureState.FAILED), + } + + assert result == expected + + +def test_get_bit_count_genesis(block_mocks: list[Block], service: FeatureService) -> None: + block = block_mocks[0] + + with pytest.raises(AssertionError) as e: + service.get_bit_count(boundary_block=block, bit=Mock()) + + assert str(e.value) == 'cannot calculate bit count for genesis' + + +@pytest.mark.parametrize('block_height', [1, 2, 3, 5, 18, 21]) +def test_get_bit_count_invalid(block_mocks: list[Block], service: FeatureService, block_height: int) -> None: + block = block_mocks[block_height] + + with pytest.raises(AssertionError) as e: + service.get_bit_count(boundary_block=block, bit=Mock()) + + assert str(e.value) == 'cannot calculate bit count for a non-boundary block' + + +@pytest.mark.parametrize( + ['block_height', 'bit', 'expected_count'], + [ + (4, 0, 0), + (4, 1, 4), + (4, 2, 2), + + (8, 0, 4), + (8, 1, 3), + (8, 2, 0), + ] +) +def test_get_bit_count( + block_mocks: list[Block], + service: FeatureService, + block_height: int, + bit: int, + expected_count: int +) -> None: + block = block_mocks[block_height] + result = service.get_bit_count(boundary_block=block, bit=bit) + + assert result == expected_count + + +@pytest.mark.parametrize( + ['block_height', 'ancestor_height'], + [ + (21, 21), + (21, 100), + (10, 15), + (10, 11), + (0, 0), + ] +) +def test_get_ancestor_at_height_invalid(block_mocks: list[Block], block_height: int, ancestor_height: int) -> None: + block = block_mocks[block_height] + + with pytest.raises(AssertionError) as e: + feature_service._get_ancestor_at_height(block=block, height=ancestor_height) + + assert str(e.value) == ( + f"ancestor height must be lower than the block's height: {ancestor_height} >= {block_height}" + ) + + +@pytest.mark.parametrize( + ['block_height', 'ancestor_height'], + [ + (21, 20), + (21, 10), + (21, 0), + (15, 10), + (15, 0), + (1, 0), + ] +) +def test_get_ancestor_at_height(block_mocks: list[Block], block_height: int, ancestor_height: int) -> None: + block = block_mocks[block_height] + result = feature_service._get_ancestor_at_height(block=block, height=ancestor_height) + + assert result.get_height() == ancestor_height diff --git a/tests/tx/test_block.py b/tests/tx/test_block.py new file mode 100644 index 000000000..0b64827c8 --- /dev/null +++ b/tests/tx/test_block.py @@ -0,0 +1,51 @@ +# Copyright 2023 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import Mock + +import pytest + +from hathor.transaction import Block, TransactionMetadata +from hathor.transaction.storage import TransactionStorage + + +def test_get_height(): + block_hash = b'some_hash' + block_height = 10 + metadata = TransactionMetadata(hash=block_hash, height=block_height) + + storage = Mock(spec_set=TransactionStorage) + storage.get_metadata = Mock(side_effect=lambda _hash: metadata if _hash == block_hash else None) + + block = Block(hash=block_hash, storage=storage) + + assert block.get_height() == block_height + + +@pytest.mark.parametrize( + ['signal_bits', 'expected_bits'], + [ + (0x00, 0), + (0x01, 1), + (0xF1, 1), + (0x06, 6), + (0xF6, 6), + (0x0F, 0xF), + (0xFF, 0xF), + ] +) +def test_get_feature_activation_bits(signal_bits: int, expected_bits: int) -> None: + block = Block(signal_bits=signal_bits) + + assert block.get_feature_activation_bits() == expected_bits