diff --git a/hathor/feature_activation/feature_service.py b/hathor/feature_activation/feature_service.py index 4d44dd5c2..a5921e830 100644 --- a/hathor/feature_activation/feature_service.py +++ b/hathor/feature_activation/feature_service.py @@ -19,9 +19,10 @@ from hathor.feature_activation.model.feature_description import FeatureDescription from hathor.feature_activation.model.feature_state import FeatureState from hathor.feature_activation.settings import Settings as FeatureSettings +from hathor.util import not_none if TYPE_CHECKING: - from hathor.transaction import Block + from hathor.transaction import Block, Transaction from hathor.transaction.storage import TransactionStorage @@ -47,8 +48,15 @@ def __init__(self, *, feature_settings: FeatureSettings, tx_storage: 'Transactio self._feature_settings = feature_settings self._tx_storage = tx_storage - def is_feature_active(self, *, block: 'Block', feature: Feature) -> bool: - """Returns whether a Feature is active at a certain block.""" + def is_feature_active_for_transaction(self, *, tx: 'Transaction', feature: Feature) -> bool: + """Return whether a Feature is active for a certain Transaction.""" + metadata = tx.get_metadata() + closest_block = self._tx_storage.get_block(not_none(metadata.closest_block)) + + return self.is_feature_active_for_block(block=closest_block, feature=feature) + + def is_feature_active_for_block(self, *, block: 'Block', feature: Feature) -> bool: + """Return whether a Feature is active for a certain block.""" state = self.get_state(block=block, feature=feature) return state == FeatureState.ACTIVE diff --git a/hathor/manager.py b/hathor/manager.py index 731e70c0a..cb9fb6259 100644 --- a/hathor/manager.py +++ b/hathor/manager.py @@ -1083,7 +1083,7 @@ def _log_feature_states(self, vertex: BaseTransaction) -> None: def _log_if_feature_is_active(self, block: Block, feature: Feature) -> None: """Log if a feature is ACTIVE for a block. Used as part of the Feature Activation Phased Testing.""" - if self._feature_service.is_feature_active(block=block, feature=feature): + if self._feature_service.is_feature_active_for_block(block=block, feature=feature): self.log.info( 'Feature is ACTIVE for block', feature=feature.value, diff --git a/hathor/transaction/base_transaction.py b/hathor/transaction/base_transaction.py index 958c59c05..9fe3b0669 100644 --- a/hathor/transaction/base_transaction.py +++ b/hathor/transaction/base_transaction.py @@ -33,7 +33,7 @@ from hathor.transaction.util import VerboseCallback, int_to_bytes, unpack, unpack_len from hathor.transaction.validation_state import ValidationState from hathor.types import TokenUid, TxOutputScript, VertexId -from hathor.util import classproperty +from hathor.util import classproperty, not_none if TYPE_CHECKING: from _hashlib import HASH @@ -708,6 +708,7 @@ def update_initial_metadata(self, *, save: bool = True) -> None: self._update_parents_children_metadata() self._update_reward_lock_metadata() self._update_feature_activation_bit_counts() + self._update_closest_block_metadata() if save: assert self.storage is not None self.storage.save_transaction(self, only_metadata=True) @@ -742,6 +743,51 @@ def _update_feature_activation_bit_counts(self) -> None: # This method lazily calculates and stores the value in metadata self.get_feature_activation_bit_counts() + def _update_closest_block_metadata(self) -> None: + """ + Set the tx's closest_block metadata. + For blocks, it's always None. For Transactions, it's the Block with the greatest height that is a direct + or indirect dependency (ancestor) of the transaction, including both funds and confirmation DAGs. + It's calculated by propagating the metadata forward in the DAG, + and it's used by Feature Activation for Transactions. + """ + from hathor.transaction import Block, Transaction + if isinstance(self, Block): + return + assert isinstance(self, Transaction) + assert self.storage is not None + metadata = self.get_metadata() + + if self.is_genesis: + metadata.closest_block = self._settings.GENESIS_BLOCK_HASH + return + + closest_block: Block | None = None + dependency_ids = self.parents + [tx_input.tx_id for tx_input in self.inputs] + + for vertex_id in dependency_ids: + vertex = self.storage.get_transaction(vertex_id) + vertex_meta = vertex.get_metadata() + this_closest_block: Block + + if isinstance(vertex, Block): + assert vertex_meta.closest_block is None + this_closest_block = vertex + elif isinstance(vertex, Transaction): + this_closest_block_id = ( + self._settings.GENESIS_BLOCK_HASH if vertex.is_genesis else not_none(vertex_meta.closest_block) + ) + this_closest_block = self.storage.get_block(this_closest_block_id) + else: + raise NotImplementedError + + if not closest_block or (this_closest_block.get_height() > closest_block.get_height()): + closest_block = this_closest_block + + assert closest_block is not None + assert closest_block.hash is not None + metadata.closest_block = closest_block.hash + def update_timestamp(self, now: int) -> None: """Update this tx's timestamp diff --git a/hathor/transaction/storage/migrations/add_closest_block_metadata.py b/hathor/transaction/storage/migrations/add_closest_block_metadata.py new file mode 100644 index 000000000..02fcb22cd --- /dev/null +++ b/hathor/transaction/storage/migrations/add_closest_block_metadata.py @@ -0,0 +1,41 @@ +# 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 +from hathor.util import progress + +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_block_metadata' + + def run(self, storage: 'TransactionStorage') -> None: + log = logger.new() + topological_iterator = storage.topological_iterator() + + for vertex in progress(topological_iterator, log=log, total=None): + if vertex.is_transaction: + vertex.update_initial_metadata() diff --git a/hathor/transaction/storage/transaction_storage.py b/hathor/transaction/storage/transaction_storage.py index 4a94f98f9..8fbffd147 100644 --- a/hathor/transaction/storage/transaction_storage.py +++ b/hathor/transaction/storage/transaction_storage.py @@ -38,6 +38,7 @@ from hathor.transaction.storage.migrations import ( BaseMigration, MigrationState, + add_closest_block_metadata, add_feature_activation_bit_counts_metadata, add_feature_activation_bit_counts_metadata2, add_min_height_metadata, @@ -92,6 +93,7 @@ class TransactionStorage(ABC): add_feature_activation_bit_counts_metadata.Migration, remove_first_nop_features.Migration, add_feature_activation_bit_counts_metadata2.Migration, + add_closest_block_metadata.Migration, ] _migrations: list[BaseMigration] diff --git a/hathor/transaction/transaction_metadata.py b/hathor/transaction/transaction_metadata.py index 17ed326a1..d46cda8e3 100644 --- a/hathor/transaction/transaction_metadata.py +++ b/hathor/transaction/transaction_metadata.py @@ -19,6 +19,7 @@ from hathor.feature_activation.feature import Feature from hathor.feature_activation.model.feature_state import FeatureState from hathor.transaction.validation_state import ValidationState +from hathor.types import VertexId from hathor.util import practically_equal if TYPE_CHECKING: @@ -56,6 +57,12 @@ class TransactionMetadata: # is None otherwise. This is only used for caching, so it can be safely cleared up, as it would be recalculated # when necessary. feature_states: Optional[dict[Feature, FeatureState]] = None + + # For Blocks, this is None. For Transactions, this is the Block with the greatest height that is a direct or + # indirect dependency (ancestor) of the transaction, including both funds and confirmation DAGs. + # It's used by Feature Activation for Transactions. + closest_block: VertexId | None + # It must be a weakref. _tx_ref: Optional['ReferenceType[BaseTransaction]'] @@ -71,7 +78,8 @@ def __init__( score: float = 0, height: Optional[int] = None, min_height: Optional[int] = None, - feature_activation_bit_counts: Optional[list[int]] = None + feature_activation_bit_counts: Optional[list[int]] = None, + closest_block: VertexId | None = None, ) -> None: from hathor.transaction.genesis import is_genesis @@ -129,6 +137,8 @@ def __init__( self.feature_activation_bit_counts = feature_activation_bit_counts + self.closest_block = closest_block + settings = get_global_settings() # Genesis specific: @@ -192,7 +202,7 @@ def __eq__(self, other: Any) -> bool: return False for field in ['hash', 'conflict_with', 'voided_by', 'received_by', 'children', 'accumulated_weight', 'twins', 'score', 'first_block', 'validation', - 'min_height', 'feature_activation_bit_counts', 'feature_states']: + 'min_height', 'feature_activation_bit_counts', 'feature_states', 'closest_block']: if (getattr(self, field) or None) != (getattr(other, field) or None): return False @@ -228,6 +238,7 @@ def to_json(self) -> dict[str, Any]: data['height'] = self.height data['min_height'] = self.min_height data['feature_activation_bit_counts'] = self.feature_activation_bit_counts + data['closest_block'] = self.closest_block.hex() if self.closest_block is not None else None if self.feature_states is not None: data['feature_states'] = {feature.value: state.value for feature, state in self.feature_states.items()} @@ -292,6 +303,9 @@ def create_from_json(cls, data: dict[str, Any]) -> 'TransactionMetadata': if first_block_raw: meta.first_block = bytes.fromhex(first_block_raw) + closest_block_raw = data.get('closest_block') + meta.closest_block = bytes.fromhex(closest_block_raw) if closest_block_raw is not None else None + _val_name = data.get('validation', None) meta.validation = ValidationState.from_name(_val_name) if _val_name is not None else ValidationState.INITIAL diff --git a/tests/feature_activation/test_feature_service.py b/tests/feature_activation/test_feature_service.py index 4a01069d3..302fe6871 100644 --- a/tests/feature_activation/test_feature_service.py +++ b/tests/feature_activation/test_feature_service.py @@ -507,7 +507,7 @@ def test_is_feature_active(block_mocks: list[Block], tx_storage: TransactionStor ) block = block_mocks[block_height] - result = service.is_feature_active(block=block, feature=Feature.NOP_FEATURE_1) + result = service.is_feature_active_for_block(block=block, feature=Feature.NOP_FEATURE_1) assert result is True