diff --git a/hathor/feature_activation/bit_signaling_service.py b/hathor/feature_activation/bit_signaling_service.py index 3d21c32e5..427eae57b 100644 --- a/hathor/feature_activation/bit_signaling_service.py +++ b/hathor/feature_activation/bit_signaling_service.py @@ -163,7 +163,7 @@ def _log_signal_bits(self, feature: Feature, enable_bit: bool, support: bool, no def _get_signaling_features(self, block: Block) -> dict[Feature, Criteria]: """Given a specific block, return all features that are in a signaling state for that block.""" - feature_infos = self._feature_service.get_feature_infos(block=block) + feature_infos = self._feature_service.get_feature_infos(vertex=block) signaling_features = { feature: feature_info.criteria for feature, feature_info in feature_infos.items() diff --git a/hathor/feature_activation/feature_service.py b/hathor/feature_activation/feature_service.py index 2b8212cef..7649589c8 100644 --- a/hathor/feature_activation/feature_service.py +++ b/hathor/feature_activation/feature_service.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + from dataclasses import dataclass from typing import TYPE_CHECKING, Optional, TypeAlias @@ -22,7 +24,7 @@ if TYPE_CHECKING: from hathor.feature_activation.bit_signaling_service import BitSignalingService - from hathor.transaction import Block + from hathor.transaction import Block, Vertex from hathor.transaction.storage import TransactionStorage @@ -49,11 +51,20 @@ def __init__(self, *, settings: HathorSettings, tx_storage: 'TransactionStorage' self._tx_storage = tx_storage self.bit_signaling_service: Optional['BitSignalingService'] = None - def is_feature_active(self, *, block: 'Block', feature: Feature) -> bool: - """Returns whether a Feature is active at a certain block.""" + def is_feature_active(self, *, vertex: Vertex, feature: Feature) -> bool: + """Return whether a Feature is active for a certain vertex.""" + block = self._get_feature_activation_block(vertex) state = self.get_state(block=block, feature=feature) + return state.is_active() - return state == FeatureState.ACTIVE + def _get_feature_activation_block(self, vertex: Vertex) -> Block: + """Return the block used for feature activation depending on the vertex type.""" + from hathor.transaction import Block, Transaction + if isinstance(vertex, Block): + return vertex + if isinstance(vertex, Transaction): + return self._tx_storage.get_block(vertex.static_metadata.closest_ancestor_block) + raise NotImplementedError def is_signaling_mandatory_features(self, block: 'Block') -> BlockSignalingState: """ @@ -64,7 +75,7 @@ def is_signaling_mandatory_features(self, block: 'Block') -> BlockSignalingState height = block.static_metadata.height offset_to_boundary = height % self._feature_settings.evaluation_interval remaining_blocks = self._feature_settings.evaluation_interval - offset_to_boundary - 1 - feature_infos = self.get_feature_infos(block=block) + feature_infos = self.get_feature_infos(vertex=block) must_signal_features = ( feature for feature, feature_info in feature_infos.items() @@ -194,8 +205,9 @@ def _calculate_new_state( raise NotImplementedError(f'Unknown previous state: {previous_state}') - def get_feature_infos(self, *, block: 'Block') -> dict[Feature, FeatureInfo]: - """Returns the criteria definition and feature state for all features at a certain block.""" + def get_feature_infos(self, *, vertex: Vertex) -> dict[Feature, FeatureInfo]: + """Return the criteria definition and feature state for all features for a certain vertex.""" + block = self._get_feature_activation_block(vertex) return { feature: FeatureInfo( criteria=criteria, @@ -204,6 +216,14 @@ def get_feature_infos(self, *, block: 'Block') -> dict[Feature, FeatureInfo]: for feature, criteria in self._feature_settings.features.items() } + def get_feature_states(self, *, vertex: Vertex) -> dict[Feature, FeatureState]: + """Return the feature state for all features for a certain vertex.""" + feature_infos = self.get_feature_infos(vertex=vertex) + return { + feature: info.state + for feature, info in feature_infos.items() + } + def _get_ancestor_at_height(self, *, block: 'Block', ancestor_height: int) -> 'Block': """ Given a block, return its ancestor at a specific height. diff --git a/hathor/feature_activation/resources/feature.py b/hathor/feature_activation/resources/feature.py index 75e7c16bf..bb65e7d67 100644 --- a/hathor/feature_activation/resources/feature.py +++ b/hathor/feature_activation/resources/feature.py @@ -68,7 +68,7 @@ def get_block_features(self, request: Request) -> bytes: return error.json_dumpb() signal_bits = [] - feature_infos = self._feature_service.get_feature_infos(block=block) + feature_infos = self._feature_service.get_feature_infos(vertex=block) for feature, feature_info in feature_infos.items(): if feature_info.state not in FeatureState.get_signaling_states(): @@ -90,7 +90,7 @@ def get_block_features(self, request: Request) -> bytes: def get_features(self) -> bytes: best_block = self.tx_storage.get_best_block() bit_counts = best_block.static_metadata.feature_activation_bit_counts - feature_infos = self._feature_service.get_feature_infos(block=best_block) + feature_infos = self._feature_service.get_feature_infos(vertex=best_block) features = [] for feature, feature_info in feature_infos.items(): diff --git a/hathor/transaction/static_metadata.py b/hathor/transaction/static_metadata.py index 09cdf98dd..03855479a 100644 --- a/hathor/transaction/static_metadata.py +++ b/hathor/transaction/static_metadata.py @@ -19,7 +19,7 @@ from operator import add from typing import TYPE_CHECKING, Callable -from typing_extensions import Self +from typing_extensions import Self, override from hathor.feature_activation.feature import Feature from hathor.feature_activation.model.feature_state import FeatureState @@ -57,6 +57,7 @@ def from_bytes(cls, data: bytes, *, target: 'BaseTransaction') -> 'VertexStaticM return BlockStaticMetadata(**json_dict) if isinstance(target, Transaction): + json_dict['closest_ancestor_block'] = bytes.fromhex(json_dict['closest_ancestor_block']) return TransactionStaticMetadata(**json_dict) raise NotImplementedError @@ -175,6 +176,10 @@ def _get_previous_feature_activation_bit_counts( class TransactionStaticMetadata(VertexStaticMetadata): + # The Block with the greatest height that is a direct or indirect dependency (ancestor) of the transaction, + # including both funds and verification DAGs. It's used by Feature Activation for Transactions. + closest_ancestor_block: VertexId + @classmethod def create_from_storage(cls, tx: 'Transaction', settings: HathorSettings, storage: 'TransactionStorage') -> Self: """Create a `TransactionStaticMetadata` using dependencies provided by a storage.""" @@ -189,14 +194,12 @@ def create( ) -> Self: """Create a `TransactionStaticMetadata` using dependencies provided by a `vertex_getter`. This must be fast, ideally O(1).""" - min_height = cls._calculate_min_height( - tx, - settings, - vertex_getter=vertex_getter, - ) + min_height = cls._calculate_min_height(tx, settings, vertex_getter) + closest_ancestor_block = cls._calculate_closest_ancestor_block(tx, settings, vertex_getter) return cls( - min_height=min_height + min_height=min_height, + closest_ancestor_block=closest_ancestor_block, ) @classmethod @@ -245,3 +248,47 @@ def _calculate_my_min_height( if isinstance(spent_tx, Block): min_height = max(min_height, spent_tx.static_metadata.height + settings.REWARD_SPEND_MIN_BLOCKS + 1) return min_height + + @staticmethod + def _calculate_closest_ancestor_block( + tx: 'Transaction', + settings: HathorSettings, + vertex_getter: Callable[[VertexId], 'BaseTransaction'], + ) -> VertexId: + """ + Calculate the tx's closest_ancestor_block. It's calculated by propagating the metadata forward in the DAG. + """ + from hathor.transaction import Block, Transaction + if tx.is_genesis: + return settings.GENESIS_BLOCK_HASH + + closest_ancestor_block: Block | None = None + + for vertex_id in tx.get_all_dependencies(): + vertex = vertex_getter(vertex_id) + candidate_block: Block + + if isinstance(vertex, Block): + candidate_block = vertex + elif isinstance(vertex, Transaction): + vertex_candidate = vertex_getter(vertex.static_metadata.closest_ancestor_block) + assert isinstance(vertex_candidate, Block) + candidate_block = vertex_candidate + else: + raise NotImplementedError + + if ( + not closest_ancestor_block + or candidate_block.static_metadata.height > closest_ancestor_block.static_metadata.height + ): + closest_ancestor_block = candidate_block + + assert closest_ancestor_block is not None + return closest_ancestor_block.hash + + @override + def json_dumpb(self) -> bytes: + from hathor.util import json_dumpb + json_dict = self.dict() + json_dict['closest_ancestor_block'] = json_dict['closest_ancestor_block'].hex() + return json_dumpb(json_dict) diff --git a/hathor/transaction/storage/migrations/add_closest_ancestor_block.py b/hathor/transaction/storage/migrations/add_closest_ancestor_block.py new file mode 100644 index 000000000..9ac3c5e8e --- /dev/null +++ b/hathor/transaction/storage/migrations/add_closest_ancestor_block.py @@ -0,0 +1,37 @@ +# 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 TYPE_CHECKING + +from structlog import get_logger + +from hathor.transaction.storage.migrations import BaseMigration + +if TYPE_CHECKING: + from hathor.transaction.storage import TransactionStorage + +logger = get_logger() + + +class Migration(BaseMigration): + def skip_empty_db(self) -> bool: + return True + + def get_db_name(self) -> str: + return 'add_closest_ancestor_block' + + def run(self, storage: 'TransactionStorage') -> None: + raise Exception('Cannot migrate your database due to an incompatible change in the metadata. ' + 'Please, delete your data folder and use the latest available snapshot or sync ' + 'from beginning.') diff --git a/hathor/verification/merge_mined_block_verifier.py b/hathor/verification/merge_mined_block_verifier.py index 60bfb42da..307604104 100644 --- a/hathor/verification/merge_mined_block_verifier.py +++ b/hathor/verification/merge_mined_block_verifier.py @@ -31,7 +31,7 @@ def verify_aux_pow(self, block: MergeMinedBlock) -> None: assert block.aux_pow is not None is_feature_active = self._feature_service.is_feature_active( - block=block, + vertex=block, feature=Feature.INCREASE_MAX_MERKLE_PATH_LENGTH ) max_merkle_path_length = ( diff --git a/hathor/vertex_handler/vertex_handler.py b/hathor/vertex_handler/vertex_handler.py index 59650e83e..f3e824bf1 100644 --- a/hathor/vertex_handler/vertex_handler.py +++ b/hathor/vertex_handler/vertex_handler.py @@ -208,24 +208,22 @@ def _log_new_object(self, tx: BaseTransaction, message_fmt: str, *, quiet: bool) """ metadata = tx.get_metadata() now = datetime.datetime.fromtimestamp(self._reactor.seconds()) + feature_states = self._feature_service.get_feature_states(vertex=tx) kwargs = { 'tx': tx, 'ts_date': datetime.datetime.fromtimestamp(tx.timestamp), 'time_from_now': tx.get_time_from_now(now), 'validation': metadata.validation.name, + 'feature_states': { + feature.value: state.value + for feature, state in feature_states.items() + } } if self._log_vertex_bytes: kwargs['bytes'] = bytes(tx).hex() - if tx.is_block: + if isinstance(tx, Block): message = message_fmt.format('block') - if isinstance(tx, Block): - feature_infos = self._feature_service.get_feature_infos(block=tx) - feature_states = { - feature.value: info.state.value - for feature, info in feature_infos.items() - } - kwargs['_height'] = tx.get_height() - kwargs['feature_states'] = feature_states + kwargs['_height'] = tx.get_height() else: message = message_fmt.format('tx') if not quiet: diff --git a/tests/feature_activation/test_bit_signaling_service.py b/tests/feature_activation/test_bit_signaling_service.py index 8b487be92..5f41ff01a 100644 --- a/tests/feature_activation/test_bit_signaling_service.py +++ b/tests/feature_activation/test_bit_signaling_service.py @@ -24,7 +24,7 @@ from hathor.feature_activation.model.feature_info import FeatureInfo from hathor.feature_activation.model.feature_state import FeatureState from hathor.feature_activation.settings import Settings as FeatureSettings -from hathor.transaction import Block +from hathor.transaction import Block, Vertex from hathor.transaction.storage import TransactionStorage @@ -169,7 +169,7 @@ def _test_generate_signal_bits( settings = Mock(spec_set=HathorSettings) settings.FEATURE_ACTIVATION = FeatureSettings() feature_service = Mock(spec_set=FeatureService) - feature_service.get_feature_infos = lambda block: feature_infos + feature_service.get_feature_infos = lambda vertex: feature_infos service = BitSignalingService( settings=settings, @@ -264,8 +264,8 @@ def test_non_signaling_features_warning( tx_storage = Mock(spec_set=TransactionStorage) tx_storage.get_best_block = lambda: best_block - def get_feature_infos_mock(block: Block) -> dict[Feature, FeatureInfo]: - if block == best_block: + def get_feature_infos_mock(vertex: Vertex) -> dict[Feature, FeatureInfo]: + if vertex == best_block: return {} raise NotImplementedError diff --git a/tests/feature_activation/test_feature_service.py b/tests/feature_activation/test_feature_service.py index cb8546bb1..f042b4e45 100644 --- a/tests/feature_activation/test_feature_service.py +++ b/tests/feature_activation/test_feature_service.py @@ -447,7 +447,7 @@ def test_is_feature_active(block_height: int) -> None: service.bit_signaling_service = Mock() block = not_none(storage.get_block_by_height(block_height)) - result = service.is_feature_active(block=block, feature=Feature.NOP_FEATURE_1) + result = service.is_feature_active(vertex=block, feature=Feature.NOP_FEATURE_1) assert result is True @@ -505,7 +505,7 @@ def get_state(self: FeatureService, *, block: Block, feature: Feature) -> Featur return states[feature] with patch('hathor.feature_activation.feature_service.FeatureService.get_state', get_state): - result = service.get_feature_infos(block=Mock()) + result = service.get_feature_infos(vertex=Mock(spec_set=Block)) expected = { Feature.NOP_FEATURE_1: FeatureInfo(criteria_mock_1, FeatureState.STARTED), diff --git a/tests/feature_activation/test_feature_simulation.py b/tests/feature_activation/test_feature_simulation.py index 6bbeb9e35..30dd9c77e 100644 --- a/tests/feature_activation/test_feature_simulation.py +++ b/tests/feature_activation/test_feature_simulation.py @@ -22,10 +22,11 @@ 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_state import FeatureState from hathor.feature_activation.resources.feature import FeatureResource from hathor.feature_activation.settings import Settings as FeatureSettings from hathor.simulator import FakeConnection -from hathor.simulator.utils import add_new_blocks +from hathor.simulator.utils import add_new_blocks, gen_new_tx from hathor.transaction.exceptions import BlockMustSignalError from hathor.util import not_none from tests import unittest @@ -75,11 +76,14 @@ def test_feature(self) -> None: } ) - settings = get_global_settings()._replace(FEATURE_ACTIVATION=feature_settings) + settings = get_global_settings()._replace(FEATURE_ACTIVATION=feature_settings, REWARD_SPEND_MIN_BLOCKS=0) + self.simulator.settings = settings builder = self.get_simulator_builder().set_settings(settings) artifacts = self.simulator.create_artifacts(builder) feature_service = artifacts.feature_service manager = artifacts.manager + assert manager.wallet is not None + address = manager.wallet.get_unused_address(mark_as_used=False) feature_resource = FeatureResource( settings=settings, @@ -95,9 +99,16 @@ def test_feature(self) -> None: patch.object(FeatureService, '_calculate_new_state', calculate_new_state_mock), patch.object(FeatureService, '_get_ancestor_iteratively', get_ancestor_iteratively_mock), ): + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [] + # at the beginning, the feature is DEFINED: - add_new_blocks(manager, 10) + [*_, last_block] = add_new_blocks(manager, 10) self.simulator.run(60) + tx = gen_new_tx(manager, address, 6400*10) + tx.weight = 25 + tx.update_hash() + assert manager.propagate_tx(tx, fails_silently=False) result = self._get_result(web_client) assert result == dict( block_height=10, @@ -121,9 +132,21 @@ def test_feature(self) -> None: assert get_ancestor_iteratively_mock.call_count == 0 calculate_new_state_mock.reset_mock() + expected_states = {Feature.NOP_FEATURE_1: FeatureState.DEFINED} + assert feature_service.get_feature_states(vertex=last_block) == expected_states + assert feature_service.get_feature_states(vertex=tx) == expected_states + assert tx.static_metadata.closest_ancestor_block == last_block.hash + + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [] + # at block 19, the feature is DEFINED, just before becoming STARTED: - add_new_blocks(manager, 9) + [*_, last_block] = add_new_blocks(manager, 9) self.simulator.run(60) + tx = gen_new_tx(manager, address, 6400*19) + tx.weight = 25 + tx.update_hash() + assert manager.propagate_tx(tx, fails_silently=False) result = self._get_result(web_client) assert result == dict( block_height=19, @@ -146,9 +169,21 @@ def test_feature(self) -> None: assert get_ancestor_iteratively_mock.call_count == 0 calculate_new_state_mock.reset_mock() + expected_states = {Feature.NOP_FEATURE_1: FeatureState.DEFINED} + assert feature_service.get_feature_states(vertex=last_block) == expected_states + assert feature_service.get_feature_states(vertex=tx) == expected_states + assert tx.static_metadata.closest_ancestor_block == last_block.hash + + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [] + # at block 20, the feature becomes STARTED: - add_new_blocks(manager, 1) + [*_, last_block] = add_new_blocks(manager, 1) self.simulator.run(60) + tx = gen_new_tx(manager, address, 6400*20) + tx.weight = 25 + tx.update_hash() + assert manager.propagate_tx(tx, fails_silently=False) result = self._get_result(web_client) assert result == dict( block_height=20, @@ -169,13 +204,25 @@ def test_feature(self) -> None: assert min(self._calculate_new_state_mock_block_height_calls(calculate_new_state_mock)) == 20 assert get_ancestor_iteratively_mock.call_count == 0 + expected_states = {Feature.NOP_FEATURE_1: FeatureState.STARTED} + assert feature_service.get_feature_states(vertex=last_block) == expected_states + assert feature_service.get_feature_states(vertex=tx) == expected_states + assert tx.static_metadata.closest_ancestor_block == last_block.hash + + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [Feature.NOP_FEATURE_1] + # we add one block before resetting the mock, just to make sure block 20 gets a chance to be saved add_new_blocks(manager, 1) calculate_new_state_mock.reset_mock() # at block 55, the feature is STARTED, just before becoming MUST_SIGNAL: - add_new_blocks(manager, 34) + [*_, last_block] = add_new_blocks(manager, 34) self.simulator.run(60) + tx = gen_new_tx(manager, address, 6400*55) + tx.weight = 30 + tx.update_hash() + assert manager.propagate_tx(tx, fails_silently=False) result = self._get_result(web_client) assert result == dict( block_height=55, @@ -197,9 +244,21 @@ def test_feature(self) -> None: assert get_ancestor_iteratively_mock.call_count == 0 calculate_new_state_mock.reset_mock() + expected_states = {Feature.NOP_FEATURE_1: FeatureState.STARTED} + assert feature_service.get_feature_states(vertex=last_block) == expected_states + assert feature_service.get_feature_states(vertex=tx) == expected_states + assert tx.static_metadata.closest_ancestor_block == last_block.hash + + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [Feature.NOP_FEATURE_1] + # at block 56, the feature becomes MUST_SIGNAL: - add_new_blocks(manager, 1) + [*_, last_block] = add_new_blocks(manager, 1) self.simulator.run(60) + tx = gen_new_tx(manager, address, 6400*56) + tx.weight = 30 + tx.update_hash() + assert manager.propagate_tx(tx, fails_silently=False) result = self._get_result(web_client) assert result == dict( block_height=56, @@ -220,6 +279,14 @@ def test_feature(self) -> None: assert min(self._calculate_new_state_mock_block_height_calls(calculate_new_state_mock)) == 56 assert get_ancestor_iteratively_mock.call_count == 0 + expected_states = {Feature.NOP_FEATURE_1: FeatureState.MUST_SIGNAL} + assert feature_service.get_feature_states(vertex=last_block) == expected_states + assert feature_service.get_feature_states(vertex=tx) == expected_states + assert tx.static_metadata.closest_ancestor_block == last_block.hash + + assert artifacts.bit_signaling_service.get_support_features() == [Feature.NOP_FEATURE_1] + assert artifacts.bit_signaling_service.get_not_support_features() == [] + # we add one block before resetting the mock, just to make sure block 56 gets a chance to be saved add_new_blocks(manager, 1, signal_bits=0b1) calculate_new_state_mock.reset_mock() @@ -236,8 +303,12 @@ def test_feature(self) -> None: assert not manager.propagate_tx(non_signaling_block) # at block 59, the feature is MUST_SIGNAL, just before becoming LOCKED_IN: - add_new_blocks(manager, num_blocks=2, signal_bits=0b1) + [*_, last_block] = add_new_blocks(manager, num_blocks=2, signal_bits=0b1) self.simulator.run(60) + tx = gen_new_tx(manager, address, 6400*59) + tx.weight = 30 + tx.update_hash() + assert manager.propagate_tx(tx, fails_silently=False) result = self._get_result(web_client) assert result == dict( block_height=59, @@ -260,9 +331,21 @@ def test_feature(self) -> None: assert get_ancestor_iteratively_mock.call_count == 0 calculate_new_state_mock.reset_mock() + expected_states = {Feature.NOP_FEATURE_1: FeatureState.MUST_SIGNAL} + assert feature_service.get_feature_states(vertex=last_block) == expected_states + assert feature_service.get_feature_states(vertex=tx) == expected_states + assert tx.static_metadata.closest_ancestor_block == last_block.hash + + assert artifacts.bit_signaling_service.get_support_features() == [Feature.NOP_FEATURE_1] + assert artifacts.bit_signaling_service.get_not_support_features() == [] + # at block 60, the feature becomes LOCKED_IN: - add_new_blocks(manager, 1) + [*_, last_block] = add_new_blocks(manager, 1) self.simulator.run(60) + tx = gen_new_tx(manager, address, 6400*60) + tx.weight = 30 + tx.update_hash() + assert manager.propagate_tx(tx, fails_silently=False) result = self._get_result(web_client) assert result == dict( block_height=60, @@ -283,13 +366,25 @@ def test_feature(self) -> None: assert min(self._calculate_new_state_mock_block_height_calls(calculate_new_state_mock)) == 60 assert get_ancestor_iteratively_mock.call_count == 0 + expected_states = {Feature.NOP_FEATURE_1: FeatureState.LOCKED_IN} + assert feature_service.get_feature_states(vertex=last_block) == expected_states + assert feature_service.get_feature_states(vertex=tx) == expected_states + assert tx.static_metadata.closest_ancestor_block == last_block.hash + + assert artifacts.bit_signaling_service.get_support_features() == [Feature.NOP_FEATURE_1] + assert artifacts.bit_signaling_service.get_not_support_features() == [] + # we add one block before resetting the mock, just to make sure block 60 gets a chance to be saved add_new_blocks(manager, 1) calculate_new_state_mock.reset_mock() # at block 71, the feature is LOCKED_IN, just before becoming ACTIVE: - add_new_blocks(manager, 10) + [*_, last_block] = add_new_blocks(manager, 10) self.simulator.run(60) + tx = gen_new_tx(manager, address, 6400*71) + tx.weight = 30 + tx.update_hash() + assert manager.propagate_tx(tx, fails_silently=False) result = self._get_result(web_client) assert result == dict( block_height=71, @@ -311,9 +406,21 @@ def test_feature(self) -> None: assert get_ancestor_iteratively_mock.call_count == 0 calculate_new_state_mock.reset_mock() + expected_states = {Feature.NOP_FEATURE_1: FeatureState.LOCKED_IN} + assert feature_service.get_feature_states(vertex=last_block) == expected_states + assert feature_service.get_feature_states(vertex=tx) == expected_states + assert tx.static_metadata.closest_ancestor_block == last_block.hash + + assert artifacts.bit_signaling_service.get_support_features() == [Feature.NOP_FEATURE_1] + assert artifacts.bit_signaling_service.get_not_support_features() == [] + # at block 72, the feature becomes ACTIVE, forever: - add_new_blocks(manager, 1) + [*_, last_block] = add_new_blocks(manager, 1) self.simulator.run(60) + tx = gen_new_tx(manager, address, 6400*72) + tx.weight = 30 + tx.update_hash() + assert manager.propagate_tx(tx, fails_silently=False) result = self._get_result(web_client) assert result == dict( block_height=72, @@ -335,6 +442,14 @@ def test_feature(self) -> None: assert get_ancestor_iteratively_mock.call_count == 0 calculate_new_state_mock.reset_mock() + expected_states = {Feature.NOP_FEATURE_1: FeatureState.ACTIVE} + assert feature_service.get_feature_states(vertex=last_block) == expected_states + assert feature_service.get_feature_states(vertex=tx) == expected_states + assert tx.static_metadata.closest_ancestor_block == last_block.hash + + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [] + def test_reorg(self) -> None: feature_settings = FeatureSettings( evaluation_interval=4, @@ -364,6 +479,9 @@ def test_reorg(self) -> None: ) web_client = StubSite(feature_resource) + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [] + # at the beginning, the feature is DEFINED: self.simulator.run(60) result = self._get_result(web_client) @@ -384,6 +502,9 @@ def test_reorg(self) -> None: ] ) + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [] + # at block 4, the feature becomes STARTED with 0% acceptance add_new_blocks(manager, 4) self.simulator.run(60) @@ -405,6 +526,9 @@ def test_reorg(self) -> None: ] ) + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [Feature.NOP_FEATURE_1] + # at block 7, acceptance is 25% (we're signaling 1 block out of 4) add_new_blocks(manager, 2) add_new_blocks(manager, 1, signal_bits=0b10) @@ -427,6 +551,9 @@ def test_reorg(self) -> None: ] ) + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [Feature.NOP_FEATURE_1] + # at block 11, acceptance is 75% (we're signaling 3 blocks out of 4), # so the feature will be locked-in in the next block add_new_blocks(manager, 1) @@ -450,6 +577,9 @@ def test_reorg(self) -> None: ] ) + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [Feature.NOP_FEATURE_1] + # at block 12, the feature is locked-in add_new_blocks(manager, 1) self.simulator.run(60) @@ -471,6 +601,9 @@ def test_reorg(self) -> None: ] ) + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [Feature.NOP_FEATURE_1] + # at block 16, the feature is activated add_new_blocks(manager, 4) self.simulator.run(60) @@ -492,6 +625,9 @@ def test_reorg(self) -> None: ] ) + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [] + # We then create a new manager with 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. builder2 = self.get_simulator_builder().set_settings(settings) @@ -523,6 +659,9 @@ def test_reorg(self) -> None: ] ) + assert artifacts.bit_signaling_service.get_support_features() == [] + assert artifacts.bit_signaling_service.get_not_support_features() == [Feature.NOP_FEATURE_1] + class BaseMemoryStorageFeatureSimulationTest(BaseFeatureSimulationTest): def get_simulator_builder(self) -> Builder: diff --git a/tests/resources/transaction/test_tx.py b/tests/resources/transaction/test_tx.py index 01cac3f8c..52f776cf0 100644 --- a/tests/resources/transaction/test_tx.py +++ b/tests/resources/transaction/test_tx.py @@ -88,7 +88,7 @@ def test_get_one_known_tx(self): '0248b9e7d6a626f45dec86975b00f4dd53f84f1f0091125250b044e49023fbbd0f74f6093cdd2226fdff3e09a1000002be') tx = Transaction.create_from_struct(bytes.fromhex(tx_hex), self.manager.tx_storage) tx.get_metadata().validation = ValidationState.FULL - tx.set_static_metadata(TransactionStaticMetadata(min_height=0)) + tx.set_static_metadata(TransactionStaticMetadata(min_height=0, closest_ancestor_block=b'')) self.manager.tx_storage.save_transaction(tx) tx_parent1_hex = ('0001010102001c382847d8440d05da95420bee2ebeb32bc437f82a9ae47b0745c8a29a7b0d001c382847d844' @@ -101,7 +101,7 @@ def test_get_one_known_tx(self): '8fb080f53a0c9c57ddb000000120') tx_parent1 = Transaction.create_from_struct(bytes.fromhex(tx_parent1_hex), self.manager.tx_storage) tx_parent1.get_metadata().validation = ValidationState.FULL - tx_parent1.set_static_metadata(TransactionStaticMetadata(min_height=0)) + tx_parent1.set_static_metadata(TransactionStaticMetadata(min_height=0, closest_ancestor_block=b'')) self.manager.tx_storage.save_transaction(tx_parent1) tx_parent2_hex = ('0001000103001f16fe62e3433bcc74b262c11a1fa94fcb38484f4d8fb080f53a0c9c57ddb001006946304402' @@ -114,7 +114,7 @@ def test_get_one_known_tx(self): 'd57709926b76e64763bf19c3f13eeac30000016d') tx_parent2 = Transaction.create_from_struct(bytes.fromhex(tx_parent2_hex), self.manager.tx_storage) tx_parent2.get_metadata().validation = ValidationState.FULL - tx_parent2.set_static_metadata(TransactionStaticMetadata(min_height=0)) + tx_parent2.set_static_metadata(TransactionStaticMetadata(min_height=0, closest_ancestor_block=b'')) self.manager.tx_storage.save_transaction(tx_parent2) tx_input_hex = ('0001010203007231eee3cb6160d95172a409d634d0866eafc8775f5729fff6a61e7850aba500b3ab76c5337b55' @@ -130,7 +130,7 @@ def test_get_one_known_tx(self): 'cfaf6e7ceb2ba91c9c84009c8174d4a46ebcc789d1989e3dec5b68cffeef239fd8cf86ef62728e2eacee000001b6') tx_input = Transaction.create_from_struct(bytes.fromhex(tx_input_hex), self.manager.tx_storage) tx_input.get_metadata().validation = ValidationState.FULL - tx_input.set_static_metadata(TransactionStaticMetadata(min_height=0)) + tx_input.set_static_metadata(TransactionStaticMetadata(min_height=0, closest_ancestor_block=b'')) self.manager.tx_storage.save_transaction(tx_input) # XXX: this is completely dependant on MemoryTokensIndex implementation, hence use_memory_storage=True @@ -198,7 +198,7 @@ def test_get_one_known_tx_with_authority(self): '5114256caacfb8f6dd13db33000020393') tx = Transaction.create_from_struct(bytes.fromhex(tx_hex), self.manager.tx_storage) tx.get_metadata().validation = ValidationState.FULL - tx.set_static_metadata(TransactionStaticMetadata(min_height=0)) + tx.set_static_metadata(TransactionStaticMetadata(min_height=0, closest_ancestor_block=b'')) self.manager.tx_storage.save_transaction(tx) tx_parent1_hex = ('0001010203000023b318c91dcfd4b967b205dc938f9f5e2fd5114256caacfb8f6dd13db330000023b318c91dcfd' @@ -214,7 +214,7 @@ def test_get_one_known_tx_with_authority(self): 'd13db3300038c3d3b69ce90bb88c0c4d6a87b9f0c349e5b10c9b7ce6714f996e512ac16400021261') tx_parent1 = Transaction.create_from_struct(bytes.fromhex(tx_parent1_hex), self.manager.tx_storage) tx_parent1.get_metadata().validation = ValidationState.FULL - tx_parent1.set_static_metadata(TransactionStaticMetadata(min_height=0)) + tx_parent1.set_static_metadata(TransactionStaticMetadata(min_height=0, closest_ancestor_block=b'')) self.manager.tx_storage.save_transaction(tx_parent1) tx_parent2_hex = ('000201040000476810205cb3625d62897fcdad620e01d66649869329640f5504d77e960d01006a473045022100c' @@ -229,7 +229,7 @@ def test_get_one_known_tx_with_authority(self): tx_parent2_bytes = bytes.fromhex(tx_parent2_hex) tx_parent2 = TokenCreationTransaction.create_from_struct(tx_parent2_bytes, self.manager.tx_storage) tx_parent2.get_metadata().validation = ValidationState.FULL - tx_parent2.set_static_metadata(TransactionStaticMetadata(min_height=0)) + tx_parent2.set_static_metadata(TransactionStaticMetadata(min_height=0, closest_ancestor_block=b'')) self.manager.tx_storage.save_transaction(tx_parent2) # Both inputs are the same as the last parent, so no need to manually add them @@ -522,7 +522,7 @@ def test_partially_validated_not_found(self): '0248b9e7d6a626f45dec86975b00f4dd53f84f1f0091125250b044e49023fbbd0f74f6093cdd2226fdff3e09a1000002be') tx = Transaction.create_from_struct(bytes.fromhex(tx_hex), self.manager.tx_storage) tx.set_validation(ValidationState.BASIC) - tx.set_static_metadata(TransactionStaticMetadata(min_height=0)) + tx.set_static_metadata(TransactionStaticMetadata(min_height=0, closest_ancestor_block=b'')) with self.manager.tx_storage.allow_partially_validated_context(): self.manager.tx_storage.save_transaction(tx) diff --git a/tests/tx/test_cache_storage.py b/tests/tx/test_cache_storage.py index bf6e9670a..f6457cd01 100644 --- a/tests/tx/test_cache_storage.py +++ b/tests/tx/test_cache_storage.py @@ -34,7 +34,7 @@ def tearDown(self): def _get_new_tx(self, nonce): from hathor.transaction.validation_state import ValidationState - tx = Transaction(nonce=nonce, storage=self.cache_storage) + tx = Transaction(nonce=nonce, storage=self.cache_storage, parents=[self._settings.GENESIS_TX1_HASH]) tx.update_hash() tx.init_static_metadata_from_storage(self._settings, self.cache_storage) meta = TransactionMetadata(hash=tx.hash) diff --git a/tests/tx/test_static_metadata.py b/tests/tx/test_static_metadata.py new file mode 100644 index 000000000..4f5464f77 --- /dev/null +++ b/tests/tx/test_static_metadata.py @@ -0,0 +1,86 @@ +# Copyright 2024 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. + +import pytest +from _pytest.fixtures import fixture + +from hathor.conf.get_settings import get_global_settings +from hathor.conf.settings import HathorSettings +from hathor.transaction import Block, Transaction, TxInput, Vertex +from hathor.transaction.static_metadata import BlockStaticMetadata, TransactionStaticMetadata +from hathor.types import VertexId + + +@fixture +def settings() -> HathorSettings: + return get_global_settings() + + +def create_block(*, vertex_id: VertexId, height: int) -> Block: + block = Block(hash=vertex_id) + block.set_static_metadata(BlockStaticMetadata( + min_height=0, + height=height, + feature_activation_bit_counts=[], + feature_states={}, + )) + return block + + +def create_tx(*, vertex_id: VertexId, closest_ancestor_block: VertexId) -> Transaction: + tx = Transaction(hash=vertex_id) + tx.set_static_metadata(TransactionStaticMetadata( + min_height=0, + closest_ancestor_block=closest_ancestor_block, + )) + return tx + + +@fixture +def tx_storage() -> dict[VertexId, Vertex]: + vertices = [ + create_block(vertex_id=b'b1', height=100), + create_block(vertex_id=b'b2', height=101), + create_block(vertex_id=b'b3', height=102), + create_block(vertex_id=b'b4', height=103), + create_tx(vertex_id=b'tx1', closest_ancestor_block=b'b1'), + create_tx(vertex_id=b'tx2', closest_ancestor_block=b'b2'), + create_tx(vertex_id=b'tx3', closest_ancestor_block=b'b4'), + ] + return {vertex.hash: vertex for vertex in vertices} + + +@pytest.mark.parametrize( + ['inputs', 'expected'], + [ + ([], b'b2'), + ([b'b1'], b'b2'), + ([b'b3'], b'b3'), + ([b'tx3'], b'b4'), + ([b'b1', b'b2', b'tx1', b'tx3'], b'b4'), + ], +) +def test_closest_ancestor_block( + settings: HathorSettings, + tx_storage: dict[VertexId, Vertex], + inputs: list[VertexId], + expected: VertexId, +) -> None: + tx = Transaction( + parents=[b'tx1', b'tx2'], + inputs=[TxInput(tx_id=vertex_id, index=0, data=b'') for vertex_id in inputs], + ) + static_metadata = TransactionStaticMetadata.create(tx, settings, lambda vertex_id: tx_storage[vertex_id]) + + assert static_metadata.closest_ancestor_block == expected diff --git a/tests/tx/test_tx.py b/tests/tx/test_tx.py index 48e1ad6e8..833d158d2 100644 --- a/tests/tx/test_tx.py +++ b/tests/tx/test_tx.py @@ -11,7 +11,7 @@ from hathor.feature_activation.feature import Feature from hathor.feature_activation.feature_service import FeatureService from hathor.simulator.utils import add_new_blocks -from hathor.transaction import MAX_OUTPUT_VALUE, Block, Transaction, TxInput, TxOutput +from hathor.transaction import MAX_OUTPUT_VALUE, Block, Transaction, TxInput, TxOutput, Vertex from hathor.transaction.exceptions import ( BlockWithInputs, ConflictingInputs, @@ -343,11 +343,11 @@ def test_merge_mined_long_merkle_path(self): patch_path = 'hathor.feature_activation.feature_service.FeatureService.is_feature_active' - def is_feature_active_false(self: FeatureService, *, block: Block, feature: Feature) -> bool: + def is_feature_active_false(self: FeatureService, *, vertex: Vertex, feature: Feature) -> bool: assert feature == Feature.INCREASE_MAX_MERKLE_PATH_LENGTH return False - def is_feature_active_true(self: FeatureService, *, block: Block, feature: Feature) -> bool: + def is_feature_active_true(self: FeatureService, *, vertex: Vertex, feature: Feature) -> bool: assert feature == Feature.INCREASE_MAX_MERKLE_PATH_LENGTH return True