diff --git a/hathor/feature_activation/feature_service.py b/hathor/feature_activation/feature_service.py index 24716eb72..b45cc717c 100644 --- a/hathor/feature_activation/feature_service.py +++ b/hathor/feature_activation/feature_service.py @@ -73,17 +73,22 @@ def _calculate_new_state( feature: Feature, previous_state: FeatureState ) -> FeatureState: - """Returns the new feature state based on the new block, the criteria, and the previous state.""" + """ + Returns the new feature state based on the new boundary block, the criteria, and the previous state. + + This method must only be called for boundary blocks, and calling it with a non-boundary block will raise + an AssertionError. Non-boundary blocks never calculate their own state, they get it from their parent block + instead. + """ height = boundary_block.get_height() criteria = self._feature_settings.features.get(feature) + evaluation_interval = self._feature_settings.evaluation_interval 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, ( - 'cannot calculate new state for a non-boundary block' - ) + assert height % evaluation_interval == 0, 'cannot calculate new state for a non-boundary block' if previous_state is FeatureState.DEFINED: if height >= criteria.start_height: @@ -92,16 +97,9 @@ def _calculate_new_state( return FeatureState.DEFINED if previous_state is FeatureState.STARTED: - if height >= criteria.timeout_height and not criteria.activate_on_timeout: + if height >= criteria.timeout_height and not criteria.lock_in_on_timeout: return FeatureState.FAILED - if ( - height >= criteria.timeout_height - and criteria.activate_on_timeout - and height >= criteria.minimum_activation_height - ): - return FeatureState.ACTIVE - # Get the count for this block's parent. Since this is a boundary block, its parent count represents the # previous evaluation interval count. parent_block = boundary_block.get_block_parent() @@ -109,15 +107,26 @@ def _calculate_new_state( count = counts[criteria.bit] threshold = criteria.get_threshold(self._feature_settings) - if ( - height < criteria.timeout_height - and count >= threshold - and height >= criteria.minimum_activation_height - ): - return FeatureState.ACTIVE + if height < criteria.timeout_height and count >= threshold: + return FeatureState.LOCKED_IN + + if (height + evaluation_interval >= criteria.timeout_height) and criteria.lock_in_on_timeout: + return FeatureState.MUST_SIGNAL return FeatureState.STARTED + if previous_state is FeatureState.MUST_SIGNAL: + # The MUST_SIGNAL state is defined to always take exactly one evaluation interval. Since this method is + # only called for boundary blocks, it is guaranteed that after exactly one evaluation interval in + # MUST_SIGNAL, the feature will transition to LOCKED_IN. + return FeatureState.LOCKED_IN + + if previous_state is FeatureState.LOCKED_IN: + if height >= criteria.minimum_activation_height: + return FeatureState.ACTIVE + + return FeatureState.LOCKED_IN + if previous_state is FeatureState.ACTIVE: return FeatureState.ACTIVE diff --git a/hathor/feature_activation/model/criteria.py b/hathor/feature_activation/model/criteria.py index 17fdf4d72..27313c112 100644 --- a/hathor/feature_activation/model/criteria.py +++ b/hathor/feature_activation/model/criteria.py @@ -42,7 +42,7 @@ class Criteria(BaseModel, validate_all=True): minimum_activation_height: the height of the first block at which the feature is allowed to become active. - activate_on_timeout: whether the feature should be activated even if the activation criteria are not met when + lock_in_on_timeout: whether the feature should be activated even if the activation criteria are not met when the timeout_height is reached, effectively forcing activation. version: the client version of hathor-core at which this feature was defined. @@ -55,7 +55,7 @@ class Criteria(BaseModel, validate_all=True): timeout_height: NonNegativeInt threshold: Optional[NonNegativeInt] = None minimum_activation_height: NonNegativeInt = 0 - activate_on_timeout: bool = False + lock_in_on_timeout: bool = False version: str = Field(..., regex=version.BUILD_VERSION_REGEX) def get_threshold(self, feature_settings: 'FeatureSettings') -> int: @@ -75,11 +75,16 @@ def _validate_bit(cls, bit: int) -> int: @validator('timeout_height') def _validate_timeout_height(cls, timeout_height: int, values: dict[str, Any]) -> int: """Validates that the timeout_height is greater than the start_height.""" + assert Criteria.evaluation_interval is not None, 'Criteria.evaluation_interval must be set' + start_height = values.get('start_height') assert start_height is not None, 'start_height must be set' - if timeout_height <= start_height: - raise ValueError(f'timeout_height must be greater than start_height: {timeout_height} <= {start_height}') + minimum_timeout_height = start_height + 2 * Criteria.evaluation_interval + + if timeout_height < minimum_timeout_height: + raise ValueError(f'timeout_height must be at least two evaluation intervals after the start_height: ' + f'{timeout_height} < {minimum_timeout_height}') return timeout_height @@ -95,20 +100,6 @@ def _validate_threshold(cls, threshold: Optional[int]) -> Optional[int]: return threshold - @validator('minimum_activation_height') - def _validate_minimum_activation_height(cls, minimum_activation_height: int, values: dict[str, Any]) -> int: - """Validates that the minimum_activation_height is not greater than the timeout_height.""" - timeout_height = values.get('timeout_height') - assert timeout_height is not None, 'timeout_height must be set' - - if minimum_activation_height > timeout_height: - raise ValueError( - f'minimum_activation_height must not be greater than timeout_height: ' - f'{minimum_activation_height} > {timeout_height}' - ) - - return minimum_activation_height - @validator('start_height', 'timeout_height', 'minimum_activation_height') def _validate_evaluation_interval_multiple(cls, value: int) -> int: """Validates that the value is a multiple of evaluation_interval.""" diff --git a/hathor/feature_activation/model/feature_state.py b/hathor/feature_activation/model/feature_state.py index 6e4932432..78c94f8a9 100644 --- a/hathor/feature_activation/model/feature_state.py +++ b/hathor/feature_activation/model/feature_state.py @@ -26,7 +26,9 @@ class FeatureState(Enum): FAILED: Represents that a certain feature is not and will never be activated. """ - DEFINED = 0 - STARTED = 1 - ACTIVE = 2 - FAILED = 3 + DEFINED = 'DEFINED' + STARTED = 'STARTED' + MUST_SIGNAL = 'MUST_SIGNAL' + LOCKED_IN = 'LOCKED_IN' + ACTIVE = 'ACTIVE' + FAILED = 'FAILED' diff --git a/hathor/feature_activation/resources/feature.py b/hathor/feature_activation/resources/feature.py index 9079ae266..1774e3caf 100644 --- a/hathor/feature_activation/resources/feature.py +++ b/hathor/feature_activation/resources/feature.py @@ -58,7 +58,7 @@ def render_GET(self, request: Request) -> bytes: threshold_percentage = threshold_count / self._feature_settings.evaluation_interval acceptance_percentage = None - if state is FeatureState.STARTED: + if state in [FeatureState.STARTED, FeatureState.MUST_SIGNAL]: acceptance_count = bit_counts[criteria.bit] acceptance_percentage = acceptance_count / self._feature_settings.evaluation_interval @@ -70,7 +70,7 @@ def render_GET(self, request: Request) -> bytes: start_height=criteria.start_height, minimum_activation_height=criteria.minimum_activation_height, timeout_height=criteria.timeout_height, - activate_on_timeout=criteria.activate_on_timeout, + lock_in_on_timeout=criteria.lock_in_on_timeout, version=criteria.version ) @@ -93,7 +93,7 @@ class GetFeatureResponse(Response, use_enum_values=True): start_height: int minimum_activation_height: int timeout_height: int - activate_on_timeout: bool + lock_in_on_timeout: bool version: str @@ -128,7 +128,7 @@ class GetFeaturesResponse(Response): 'start_height': 0, 'minimum_activation_height': 0, 'timeout_height': 100, - 'activate_on_timeout': False, + 'lock_in_on_timeout': False, 'version': '0.1.0' }, { @@ -139,7 +139,7 @@ class GetFeaturesResponse(Response): 'start_height': 200, 'minimum_activation_height': 0, 'timeout_height': 300, - 'activate_on_timeout': False, + 'lock_in_on_timeout': False, 'version': '0.2.0' } ] diff --git a/tests/feature_activation/test_criteria.py b/tests/feature_activation/test_criteria.py index 9f6faaf37..617a86dd9 100644 --- a/tests/feature_activation/test_criteria.py +++ b/tests/feature_activation/test_criteria.py @@ -22,10 +22,10 @@ VALID_CRITERIA = dict( bit=0, start_height=1000, - timeout_height=2000, + timeout_height=3000, threshold=0, minimum_activation_height=0, - activate_on_timeout=False, + lock_in_on_timeout=False, version='0.0.0' ) @@ -43,7 +43,7 @@ class TestCriteria: timeout_height=102_000, threshold=1000, minimum_activation_height=101_000, - activate_on_timeout=True, + lock_in_on_timeout=True, version='0.52.3' ) ] @@ -91,10 +91,10 @@ def test_start_height(self, start_height, error): [ (-10, 'ensure this value is greater than or equal to 0'), (-1, 'ensure this value is greater than or equal to 0'), - (1, 'timeout_height must be greater than start_height: 1 <= 1000'), - (45, 'timeout_height must be greater than start_height: 45 <= 1000'), - (100, 'timeout_height must be greater than start_height: 100 <= 1000'), - (1111, 'Should be a multiple of evaluation_interval: 1111 % 1000 != 0') + (1, 'timeout_height must be at least two evaluation intervals after the start_height: 1 < 3000'), + (45, 'timeout_height must be at least two evaluation intervals after the start_height: 45 < 3000'), + (100, 'timeout_height must be at least two evaluation intervals after the start_height: 100 < 3000'), + (3111, 'Should be a multiple of evaluation_interval: 3111 % 1000 != 0') ] ) def test_timeout_height(self, timeout_height, error): @@ -130,7 +130,6 @@ def test_threshold(self, threshold, error): (1, 'Should be a multiple of evaluation_interval: 1 % 1000 != 0'), (45, 'Should be a multiple of evaluation_interval: 45 % 1000 != 0'), (100, 'Should be a multiple of evaluation_interval: 100 % 1000 != 0'), - (10_000, 'minimum_activation_height must not be greater than timeout_height: 10000 > 2000') ] ) def test_minimum_activation_height(self, minimum_activation_height, error): diff --git a/tests/feature_activation/test_feature_service.py b/tests/feature_activation/test_feature_service.py index d99d47e3c..4cc781095 100644 --- a/tests/feature_activation/test_feature_service.py +++ b/tests/feature_activation/test_feature_service.py @@ -60,6 +60,11 @@ def _get_blocks_and_storage() -> tuple[list[Block], TransactionStorage]: 0b0000, # 20: boundary block 0b0000, + 0b0000, + 0b0000, + + 0b0000, # 24: boundary block + 0b0000, ] storage = Mock() storage.get_metadata = Mock(return_value=None) @@ -164,8 +169,8 @@ def test_get_state_from_defined( assert result == expected_state -@pytest.mark.parametrize('block_height', [8, 9, 10, 11, 12, 13]) -@pytest.mark.parametrize('timeout_height', [4, 8]) +@pytest.mark.parametrize('block_height', [12, 13, 14, 15, 16, 17]) +@pytest.mark.parametrize('timeout_height', [8, 12]) def test_get_state_from_started_to_failed( block_mocks: list[Block], tx_storage: TransactionStorage, @@ -176,10 +181,10 @@ def test_get_state_from_started_to_failed( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( - bit=Mock(), + bit=3, start_height=0, timeout_height=timeout_height, - activate_on_timeout=False, + lock_in_on_timeout=False, version=Mock() ) } @@ -195,25 +200,22 @@ def test_get_state_from_started_to_failed( 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( +@pytest.mark.parametrize('block_height', [8, 9, 10, 11]) +@pytest.mark.parametrize('timeout_height', [8, 12]) +def test_get_state_from_started_to_must_signal_on_timeout( block_mocks: list[Block], tx_storage: TransactionStorage, block_height: int, timeout_height: int, - minimum_activation_height: int ) -> None: feature_settings = FeatureSettings.construct( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( - bit=Mock(), + bit=3, start_height=0, timeout_height=timeout_height, - activate_on_timeout=True, - minimum_activation_height=minimum_activation_height, + lock_in_on_timeout=True, version=Mock() ) } @@ -226,17 +228,15 @@ def test_get_state_from_started_to_active_on_timeout( result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) - assert result == FeatureState.ACTIVE + assert result == FeatureState.MUST_SIGNAL -@pytest.mark.parametrize('block_height', [8, 9, 10, 11, 12, 13]) -@pytest.mark.parametrize('minimum_activation_height', [0, 4, 8]) +@pytest.mark.parametrize('block_height', [8, 9, 10, 11]) @pytest.mark.parametrize('default_threshold', [0, 1, 2, 3]) -def test_get_state_from_started_to_active_on_default_threshold( +def test_get_state_from_started_to_locked_in_on_default_threshold( block_mocks: list[Block], tx_storage: TransactionStorage, block_height: int, - minimum_activation_height: int, default_threshold: int ) -> None: feature_settings = FeatureSettings.construct( @@ -248,7 +248,6 @@ def test_get_state_from_started_to_active_on_default_threshold( start_height=0, timeout_height=400, threshold=None, - minimum_activation_height=minimum_activation_height, version=Mock() ) } @@ -261,17 +260,15 @@ def test_get_state_from_started_to_active_on_default_threshold( result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) - assert result == FeatureState.ACTIVE + assert result == FeatureState.LOCKED_IN -@pytest.mark.parametrize('block_height', [8, 9, 10, 11, 12, 13]) -@pytest.mark.parametrize('minimum_activation_height', [0, 4, 8]) +@pytest.mark.parametrize('block_height', [8, 9, 10, 11]) @pytest.mark.parametrize('custom_threshold', [0, 1, 2, 3]) -def test_get_state_from_started_to_active_on_custom_threshold( +def test_get_state_from_started_to_locked_in_on_custom_threshold( block_mocks: list[Block], tx_storage: TransactionStorage, block_height: int, - minimum_activation_height: int, custom_threshold: int ) -> None: feature_settings = FeatureSettings.construct( @@ -282,7 +279,6 @@ def test_get_state_from_started_to_active_on_custom_threshold( start_height=0, timeout_height=400, threshold=custom_threshold, - minimum_activation_height=minimum_activation_height, version=Mock() ) } @@ -295,25 +291,24 @@ def test_get_state_from_started_to_active_on_custom_threshold( result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) - assert result == FeatureState.ACTIVE + assert result == FeatureState.LOCKED_IN @pytest.mark.parametrize('block_height', [8, 9, 10, 11]) @pytest.mark.parametrize( - ['activate_on_timeout', 'timeout_height', 'minimum_activation_height'], + ['lock_in_on_timeout', 'timeout_height'], [ - (False, 12, 0), - (True, 4, 12), - (True, 8, 12), + (False, 12), + (True, 16), + (True, 20), ] ) def test_get_state_from_started_to_started( block_mocks: list[Block], tx_storage: TransactionStorage, block_height: int, - activate_on_timeout: bool, + lock_in_on_timeout: bool, timeout_height: int, - minimum_activation_height: int ) -> None: feature_settings = FeatureSettings.construct( evaluation_interval=4, @@ -322,8 +317,7 @@ def test_get_state_from_started_to_started( bit=3, start_height=0, timeout_height=timeout_height, - activate_on_timeout=activate_on_timeout, - minimum_activation_height=minimum_activation_height, + lock_in_on_timeout=lock_in_on_timeout, version=Mock() ) } @@ -340,15 +334,108 @@ def test_get_state_from_started_to_started( @pytest.mark.parametrize('block_height', [12, 13, 14, 15]) +def test_get_state_from_must_signal_to_locked_in( + block_mocks: list[Block], + tx_storage: TransactionStorage, + block_height: int, +) -> None: + feature_settings = FeatureSettings.construct( + evaluation_interval=4, + features={ + Feature.NOP_FEATURE_1: Criteria.construct( + bit=3, + start_height=0, + timeout_height=8, + lock_in_on_timeout=True, + version=Mock() + ) + } + ) + service = FeatureService( + feature_settings=feature_settings, + tx_storage=tx_storage + ) + block = block_mocks[block_height] + + result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) + + assert result == FeatureState.LOCKED_IN + + +@pytest.mark.parametrize('block_height', [16, 17, 18, 19]) +@pytest.mark.parametrize('minimum_activation_height', [0, 4, 8, 12, 16]) +def test_get_state_from_locked_in_to_active( + block_mocks: list[Block], + tx_storage: TransactionStorage, + block_height: int, + minimum_activation_height: int, +) -> None: + feature_settings = FeatureSettings.construct( + evaluation_interval=4, + features={ + Feature.NOP_FEATURE_1: Criteria.construct( + bit=3, + start_height=0, + timeout_height=8, + minimum_activation_height=minimum_activation_height, + lock_in_on_timeout=True, + version=Mock() + ) + } + ) + service = FeatureService( + feature_settings=feature_settings, + tx_storage=tx_storage + ) + 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', [16, 17, 18, 19]) +@pytest.mark.parametrize('minimum_activation_height', [17, 20, 100]) +def test_get_state_from_locked_in_to_locked_in( + block_mocks: list[Block], + tx_storage: TransactionStorage, + block_height: int, + minimum_activation_height: int, +) -> None: + feature_settings = FeatureSettings.construct( + evaluation_interval=4, + features={ + Feature.NOP_FEATURE_1: Criteria.construct( + bit=3, + start_height=0, + timeout_height=8, + minimum_activation_height=minimum_activation_height, + lock_in_on_timeout=True, + version=Mock() + ) + } + ) + service = FeatureService( + feature_settings=feature_settings, + tx_storage=tx_storage + ) + block = block_mocks[block_height] + + result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) + + assert result == FeatureState.LOCKED_IN + + +@pytest.mark.parametrize('block_height', [20, 21, 22, 23]) def test_get_state_from_active(block_mocks: list[Block], tx_storage: TransactionStorage, block_height: int) -> None: feature_settings = FeatureSettings.construct( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( - bit=Mock(), + bit=3, start_height=0, - timeout_height=4, - activate_on_timeout=True, + timeout_height=8, + lock_in_on_timeout=True, version=Mock() ) } @@ -364,16 +451,16 @@ def test_get_state_from_active(block_mocks: list[Block], tx_storage: Transaction assert result == FeatureState.ACTIVE -@pytest.mark.parametrize('block_height', [12, 13, 14, 15]) +@pytest.mark.parametrize('block_height', [16, 17, 18, 19]) def test_caching_mechanism(block_mocks: list[Block], tx_storage: TransactionStorage, block_height: int) -> None: feature_settings = FeatureSettings.construct( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( - bit=Mock(), + bit=3, start_height=0, - timeout_height=4, - activate_on_timeout=True, + timeout_height=8, + lock_in_on_timeout=True, version=Mock() ) } @@ -386,24 +473,24 @@ def test_caching_mechanism(block_mocks: list[Block], tx_storage: TransactionStor result1 = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) assert result1 == FeatureState.ACTIVE - assert calculate_new_state_mock.call_count == 3 + assert calculate_new_state_mock.call_count == 4 result2 = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) assert result2 == FeatureState.ACTIVE - assert calculate_new_state_mock.call_count == 3 + assert calculate_new_state_mock.call_count == 4 -@pytest.mark.parametrize('block_height', [12, 13, 14, 15]) +@pytest.mark.parametrize('block_height', [16, 17, 18, 19]) def test_is_feature_active(block_mocks: list[Block], tx_storage: TransactionStorage, block_height: int) -> None: feature_settings = FeatureSettings.construct( evaluation_interval=4, features={ Feature.NOP_FEATURE_1: Criteria.construct( - bit=Mock(), + bit=3, start_height=0, - timeout_height=4, - activate_on_timeout=True, + timeout_height=8, + lock_in_on_timeout=True, version=Mock() ) } @@ -427,7 +514,7 @@ def test_get_state_from_failed(block_mocks: list[Block], tx_storage: Transaction Feature.NOP_FEATURE_1: Criteria.construct( bit=Mock(), start_height=0, - timeout_height=4, + timeout_height=8, version=Mock() ) } diff --git a/tests/feature_activation/test_feature_simulation.py b/tests/feature_activation/test_feature_simulation.py index ed31c1f63..bb4faeae5 100644 --- a/tests/feature_activation/test_feature_simulation.py +++ b/tests/feature_activation/test_feature_simulation.py @@ -68,7 +68,8 @@ def test_feature(self) -> None: bit=0, start_height=20, timeout_height=60, - activate_on_timeout=True, + minimum_activation_height=72, + lock_in_on_timeout=True, version='0.0.0' ) } @@ -107,8 +108,8 @@ def test_feature(self) -> None: threshold=0.75, start_height=20, timeout_height=60, - minimum_activation_height=0, - activate_on_timeout=True, + minimum_activation_height=72, + lock_in_on_timeout=True, version='0.0.0' ) ] @@ -133,8 +134,8 @@ def test_feature(self) -> None: threshold=0.75, start_height=20, timeout_height=60, - minimum_activation_height=0, - activate_on_timeout=True, + minimum_activation_height=72, + lock_in_on_timeout=True, version='0.0.0' ) ] @@ -158,8 +159,8 @@ def test_feature(self) -> None: threshold=0.75, start_height=20, timeout_height=60, - minimum_activation_height=0, - activate_on_timeout=True, + minimum_activation_height=72, + lock_in_on_timeout=True, version='0.0.0' ) ] @@ -168,12 +169,12 @@ def test_feature(self) -> None: assert get_ancestor_iteratively_mock.call_count == 0 get_state_mock.reset_mock() - # at block 39, the feature is STARTED, just before becoming ACTIVE: - trigger = StopAfterNMinedBlocks(miner, quantity=39) + # at block 55, the feature is STARTED, just before becoming MUST_SIGNAL: + trigger = StopAfterNMinedBlocks(miner, quantity=35) self.simulator.run(36000, trigger=trigger) result = self._get_result(web_client) assert result == dict( - block_height=59, + block_height=55, features=[ dict( name='NOP_FEATURE_1', @@ -182,19 +183,69 @@ def test_feature(self) -> None: threshold=0.75, start_height=20, timeout_height=60, - minimum_activation_height=0, - activate_on_timeout=True, + minimum_activation_height=72, + lock_in_on_timeout=True, version='0.0.0' ) ] ) assert ( - self._get_state_mock_block_height_calls(get_state_mock) == [59, 56, 52, 48, 44, 40, 36, 32, 28, 24, 20] + self._get_state_mock_block_height_calls(get_state_mock) == [55, 52, 48, 44, 40, 36, 32, 28, 24, 20] ) assert get_ancestor_iteratively_mock.call_count == 0 get_state_mock.reset_mock() - # at block 60, the feature becomes ACTIVE, forever: + # at block 56, the feature becomes MUST_SIGNAL: + trigger = StopAfterNMinedBlocks(miner, quantity=1) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) + assert result == dict( + block_height=56, + features=[ + dict( + name='NOP_FEATURE_1', + state='MUST_SIGNAL', + acceptance=0, + threshold=0.75, + start_height=20, + timeout_height=60, + minimum_activation_height=72, + lock_in_on_timeout=True, + version='0.0.0' + ) + ] + ) + assert self._get_state_mock_block_height_calls(get_state_mock) == [56, 52] + assert get_ancestor_iteratively_mock.call_count == 0 + get_state_mock.reset_mock() + + # at block 59, the feature is MUST_SIGNAL, just before becoming LOCKED_IN: + trigger = StopAfterNMinedBlocks(miner, quantity=3) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) + assert result == dict( + block_height=59, + features=[ + dict( + name='NOP_FEATURE_1', + state='MUST_SIGNAL', + acceptance=0, + threshold=0.75, + start_height=20, + timeout_height=60, + minimum_activation_height=72, + lock_in_on_timeout=True, + version='0.0.0' + ) + ] + ) + assert ( + self._get_state_mock_block_height_calls(get_state_mock) == [59, 56] + ) + assert get_ancestor_iteratively_mock.call_count == 0 + get_state_mock.reset_mock() + + # at block 60, the feature becomes LOCKED_IN: trigger = StopAfterNMinedBlocks(miner, quantity=1) self.simulator.run(36000, trigger=trigger) result = self._get_result(web_client) @@ -203,13 +254,13 @@ def test_feature(self) -> None: features=[ dict( name='NOP_FEATURE_1', - state='ACTIVE', + state='LOCKED_IN', acceptance=None, threshold=0.75, start_height=20, timeout_height=60, - minimum_activation_height=0, - activate_on_timeout=True, + minimum_activation_height=72, + lock_in_on_timeout=True, version='0.0.0' ) ] @@ -218,6 +269,56 @@ def test_feature(self) -> None: assert get_ancestor_iteratively_mock.call_count == 0 get_state_mock.reset_mock() + # at block 71, the feature is LOCKED_IN, just before becoming ACTIVE: + trigger = StopAfterNMinedBlocks(miner, quantity=11) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) + assert result == dict( + block_height=71, + features=[ + dict( + name='NOP_FEATURE_1', + state='LOCKED_IN', + acceptance=None, + threshold=0.75, + start_height=20, + timeout_height=60, + minimum_activation_height=72, + lock_in_on_timeout=True, + version='0.0.0' + ) + ] + ) + assert ( + self._get_state_mock_block_height_calls(get_state_mock) == [71, 68, 64, 60] + ) + assert get_ancestor_iteratively_mock.call_count == 0 + get_state_mock.reset_mock() + + # at block 72, the feature becomes ACTIVE, forever: + trigger = StopAfterNMinedBlocks(miner, quantity=1) + self.simulator.run(36000, trigger=trigger) + result = self._get_result(web_client) + assert result == dict( + block_height=72, + features=[ + dict( + name='NOP_FEATURE_1', + state='ACTIVE', + acceptance=None, + threshold=0.75, + start_height=20, + timeout_height=60, + minimum_activation_height=72, + lock_in_on_timeout=True, + version='0.0.0' + ) + ] + ) + assert self._get_state_mock_block_height_calls(get_state_mock) == [72, 68] + 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 @@ -232,7 +333,7 @@ def test_reorg(self) -> None: bit=1, start_height=4, timeout_height=100, - activate_on_timeout=False, + lock_in_on_timeout=False, version='0.0.0' ) } @@ -250,7 +351,6 @@ def test_reorg(self) -> None: 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 ] @@ -272,7 +372,7 @@ def test_reorg(self) -> None: start_height=4, timeout_height=100, minimum_activation_height=0, - activate_on_timeout=False, + lock_in_on_timeout=False, version='0.0.0' ) ] @@ -293,7 +393,7 @@ def test_reorg(self) -> None: start_height=4, timeout_height=100, minimum_activation_height=0, - activate_on_timeout=False, + lock_in_on_timeout=False, version='0.0.0' ) ] @@ -314,13 +414,13 @@ def test_reorg(self) -> None: start_height=4, timeout_height=100, minimum_activation_height=0, - activate_on_timeout=False, + lock_in_on_timeout=False, version='0.0.0' ) ] ) - # at block 11, acceptance was 50% + # at block 11, acceptance was 75%, so the feature will be locked-in in the next block trigger = StopAfterNMinedBlocks(miner, quantity=4) self.simulator.run(36000, trigger=trigger) result = self._get_result(web_client) @@ -330,40 +430,40 @@ def test_reorg(self) -> None: dict( name='NOP_FEATURE_1', state='STARTED', - acceptance=0.5, + acceptance=0.75, threshold=0.75, start_height=4, timeout_height=100, minimum_activation_height=0, - activate_on_timeout=False, + lock_in_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) + # at block 12, the feature is locked-in + trigger = StopAfterNMinedBlocks(miner, quantity=1) self.simulator.run(36000, trigger=trigger) result = self._get_result(web_client) assert result == dict( - block_height=15, + block_height=12, features=[ dict( name='NOP_FEATURE_1', - state='STARTED', - acceptance=0.75, + state='LOCKED_IN', + acceptance=None, threshold=0.75, start_height=4, timeout_height=100, minimum_activation_height=0, - activate_on_timeout=False, + lock_in_on_timeout=False, version='0.0.0' ) ] ) # at block 16, the feature is activated - trigger = StopAfterNMinedBlocks(miner, quantity=1) + trigger = StopAfterNMinedBlocks(miner, quantity=4) self.simulator.run(36000, trigger=trigger) result = self._get_result(web_client) assert result == dict( @@ -377,7 +477,7 @@ def test_reorg(self) -> None: start_height=4, timeout_height=100, minimum_activation_height=0, - activate_on_timeout=False, + lock_in_on_timeout=False, version='0.0.0' ) ] @@ -413,7 +513,7 @@ def test_reorg(self) -> None: start_height=4, timeout_height=100, minimum_activation_height=0, - activate_on_timeout=False, + lock_in_on_timeout=False, version='0.0.0' ) ] @@ -456,7 +556,7 @@ def test_feature_from_existing_storage(self) -> None: bit=0, start_height=20, timeout_height=60, - activate_on_timeout=True, + lock_in_on_timeout=True, version='0.0.0' ) } @@ -483,11 +583,11 @@ def test_feature_from_existing_storage(self) -> None: ): assert artifacts1.tx_storage.get_vertices_count() == 3 # genesis vertices in the storage - trigger = StopAfterNMinedBlocks(miner, quantity=60) + trigger = StopAfterNMinedBlocks(miner, quantity=64) self.simulator.run(36000, trigger=trigger) result = self._get_result(web_client) assert result == dict( - block_height=60, + block_height=64, features=[ dict( name='NOP_FEATURE_1', @@ -497,17 +597,17 @@ def test_feature_from_existing_storage(self) -> None: start_height=20, timeout_height=60, minimum_activation_height=0, - activate_on_timeout=True, + lock_in_on_timeout=True, version='0.0.0' ) ] ) # feature states have to be calculated for all blocks in evaluation interval boundaries, as this is the # first run: - assert self._get_state_mock_block_height_calls(get_state_mock) == list(range(60, -4, -4)) + assert self._get_state_mock_block_height_calls(get_state_mock) == list(range(64, -4, -4)) # no blocks are voided, so we only use the height index: assert get_ancestor_iteratively_mock.call_count == 0 - assert artifacts1.tx_storage.get_vertices_count() == 63 + assert artifacts1.tx_storage.get_vertices_count() == 67 get_state_mock.reset_mock() miner.stop() @@ -537,13 +637,13 @@ def test_feature_from_existing_storage(self) -> None: patch.object(feature_service_module, '_get_ancestor_iteratively', get_ancestor_iteratively_mock) ): # the new storage starts populated - assert artifacts2.tx_storage.get_vertices_count() == 63 + assert artifacts2.tx_storage.get_vertices_count() == 67 self.simulator.run(3600) result = self._get_result(web_client) assert result == dict( - block_height=60, + block_height=64, features=[ dict( name='NOP_FEATURE_1', @@ -553,15 +653,15 @@ def test_feature_from_existing_storage(self) -> None: start_height=20, timeout_height=60, minimum_activation_height=0, - activate_on_timeout=True, + lock_in_on_timeout=True, version='0.0.0' ) ] ) # features states are not queried for previous blocks, as they have it cached: - assert self._get_state_mock_block_height_calls(get_state_mock) == [60] + assert self._get_state_mock_block_height_calls(get_state_mock) == [64] assert get_ancestor_iteratively_mock.call_count == 0 - assert artifacts2.tx_storage.get_vertices_count() == 63 + assert artifacts2.tx_storage.get_vertices_count() == 67 get_state_mock.reset_mock() diff --git a/tests/feature_activation/test_settings.py b/tests/feature_activation/test_settings.py index 8e5f0e555..04af34229 100644 --- a/tests/feature_activation/test_settings.py +++ b/tests/feature_activation/test_settings.py @@ -26,14 +26,14 @@ NOP_FEATURE_1=dict( bit=0, start_height=0, - timeout_height=40320, + timeout_height=80640, threshold=0, version='0.0.0' ), NOP_FEATURE_2=dict( bit=1, start_height=0, - timeout_height=40320, + timeout_height=80640, threshold=0, version='0.0.0' ) @@ -42,14 +42,14 @@ NOP_FEATURE_1=dict( bit=0, start_height=0, - timeout_height=40320, + timeout_height=80640, threshold=0, version='0.0.0' ), NOP_FEATURE_2=dict( bit=0, - start_height=2 * 40320, - timeout_height=3 * 40320, + start_height=3 * 40320, + timeout_height=5 * 40320, threshold=0, version='0.0.0' ) @@ -68,14 +68,14 @@ def test_valid_settings(features): NOP_FEATURE_1=dict( bit=0, start_height=0, - timeout_height=40320, + timeout_height=80640, threshold=0, version='0.0.0' ), NOP_FEATURE_2=dict( bit=0, start_height=0, - timeout_height=40320, + timeout_height=80640, threshold=0, version='0.0.0' ) @@ -84,14 +84,14 @@ def test_valid_settings(features): NOP_FEATURE_1=dict( bit=0, start_height=0, - timeout_height=40320, + timeout_height=80640, threshold=0, version='0.0.0' ), NOP_FEATURE_2=dict( bit=0, start_height=40320, - timeout_height=2 * 40320, + timeout_height=3 * 40320, threshold=0, version='0.0.0' ) @@ -107,7 +107,7 @@ def test_valid_settings(features): NOP_FEATURE_2=dict( bit=1, start_height=15 * 40320, - timeout_height=16 * 40320, + timeout_height=17 * 40320, threshold=0, version='0.0.0' ) diff --git a/tests/others/fixtures/invalid_features_hathor_settings_fixture.yml b/tests/others/fixtures/invalid_features_hathor_settings_fixture.yml index 4d69c84e6..e993f0cee 100644 --- a/tests/others/fixtures/invalid_features_hathor_settings_fixture.yml +++ b/tests/others/fixtures/invalid_features_hathor_settings_fixture.yml @@ -45,10 +45,10 @@ FEATURE_ACTIVATION: NOP_FEATURE_1: bit: 0 start_height: 0 - timeout_height: 1000 + timeout_height: 2000 version: 0.0.0 NOP_FEATURE_2: bit: 1 start_height: 0 - timeout_height: 1001 + timeout_height: 2001 version: 0.0.0 diff --git a/tests/others/test_hathor_settings.py b/tests/others/test_hathor_settings.py index 46c305db1..e0bcf82e9 100644 --- a/tests/others/test_hathor_settings.py +++ b/tests/others/test_hathor_settings.py @@ -75,7 +75,7 @@ def test_valid_hathor_settings_from_yaml(filepath): ('fixtures/invalid_byte_hathor_settings_fixture.yml', "expected 'str' or 'bytes', got 64"), ( 'fixtures/invalid_features_hathor_settings_fixture.yml', - 'Should be a multiple of evaluation_interval: 1001 % 1000 != 0' + 'Should be a multiple of evaluation_interval: 2001 % 1000 != 0' ) ] ) diff --git a/tests/resources/feature/test_feature.py b/tests/resources/feature/test_feature.py index fa586df1d..5dcb83f4f 100644 --- a/tests/resources/feature/test_feature.py +++ b/tests/resources/feature/test_feature.py @@ -87,7 +87,7 @@ def test_get_features(web): start_height=0, minimum_activation_height=0, timeout_height=100, - activate_on_timeout=False, + lock_in_on_timeout=False, version='0.1.0' ), dict( @@ -98,7 +98,7 @@ def test_get_features(web): start_height=200, minimum_activation_height=0, timeout_height=300, - activate_on_timeout=False, + lock_in_on_timeout=False, version='0.2.0' ) ]