diff --git a/hathor/manager.py b/hathor/manager.py index 4750172e4..26b9a8c49 100644 --- a/hathor/manager.py +++ b/hathor/manager.py @@ -15,8 +15,9 @@ import sys import time from cProfile import Profile +from dataclasses import dataclass from enum import Enum -from typing import TYPE_CHECKING, Iterator, NamedTuple, Optional, Union +from typing import TYPE_CHECKING, Iterator, Optional, Union from hathorlib.base_transaction import tx_or_block_from_bytes as lib_tx_or_block_from_bytes from structlog import get_logger @@ -561,25 +562,49 @@ def get_new_tx_parents(self, timestamp: Optional[float] = None) -> list[VertexId return list(parent_txs.get_random_parents(self.rng)) def generate_parent_txs(self, timestamp: Optional[float]) -> 'ParentTxs': - """Select which transactions will be confirmed by a new block. + """Select which transactions will be confirmed by a new block or transaction. This method tries to return a stable result, such that for a given timestamp and storage state it will always return the same. """ if timestamp is None: timestamp = self.reactor.seconds() + can_include_intervals = sorted(self.tx_storage.get_tx_tips(timestamp - 1)) assert can_include_intervals, 'tips cannot be empty' - max_timestamp = max(int(i.begin) for i in can_include_intervals) - must_include: list[VertexId] = [] - assert len(can_include_intervals) > 0, f'invalid timestamp "{timestamp}", no tips found"' - if len(can_include_intervals) < 2: - # If there is only one tip, let's randomly choose one of its parents. - must_include_interval = can_include_intervals[0] - must_include = [must_include_interval.data] - can_include_intervals = sorted(self.tx_storage.get_tx_tips(must_include_interval.begin - 1)) - can_include = [i.data for i in can_include_intervals] - return ParentTxs(max_timestamp, can_include, must_include) + + confirmed_tips: list[Transaction] = [] + unconfirmed_tips: list[Transaction] = [] + for interval in can_include_intervals: + tx = self.tx_storage.get_tx(interval.data) + tips = unconfirmed_tips if tx.get_metadata().first_block is None else confirmed_tips + tips.append(tx) + + def get_tx_parents(tx: Transaction) -> list[Transaction]: + if tx.is_genesis: + other_genesis = {self._settings.GENESIS_TX1_HASH, self._settings.GENESIS_TX2_HASH} - {tx.hash} + assert len(other_genesis) == 1 + return [self.tx_storage.get_tx(vertex_id) for vertex_id in other_genesis] + + parents = tx.get_tx_parents() + assert len(parents) == 2 + return list(parents) + + match unconfirmed_tips: + case []: + if len(confirmed_tips) >= 2: + return ParentTxs.from_txs(can_include=confirmed_tips, must_include=()) + assert len(confirmed_tips) == 1 + tx = confirmed_tips[0] + return ParentTxs.from_txs(can_include=get_tx_parents(tx), must_include=(tx,)) + + case [tip_tx]: + if len(confirmed_tips) >= 1: + return ParentTxs.from_txs(can_include=confirmed_tips, must_include=(tip_tx,)) + return ParentTxs.from_txs(can_include=get_tx_parents(tip_tx), must_include=(tip_tx,)) + + case _: + return ParentTxs.from_txs(can_include=unconfirmed_tips, must_include=()) def allow_mining_without_peers(self) -> None: """Allow mining without being synced to at least one peer. @@ -637,14 +662,12 @@ def make_custom_block_template(self, parent_block_hash: VertexId, parent_tx_hash """ parent_block = self.tx_storage.get_transaction(parent_block_hash) assert isinstance(parent_block, Block) - # gather the actual txs to query their timestamps parent_tx_list: list[Transaction] = [] for tx_hash in parent_tx_hashes: tx = self.tx_storage.get_transaction(tx_hash) assert isinstance(tx, Transaction) parent_tx_list.append(tx) - max_timestamp = max(tx.timestamp for tx in parent_tx_list) - parent_txs = ParentTxs(max_timestamp, parent_tx_hashes, []) + parent_txs = ParentTxs.from_txs(can_include=parent_tx_list, must_include=()) if timestamp is None: current_timestamp = int(max(self.tx_storage.latest_timestamp, self.reactor.seconds())) else: @@ -698,7 +721,7 @@ def _make_block_template(self, parent_block: Block, parent_txs: 'ParentTxs', cur min_significant_weight ) height = parent_block.get_height() + 1 - parents = [parent_block.hash] + parent_txs.must_include + parents = [parent_block.hash] + list(parent_txs.must_include) parents_any = parent_txs.can_include # simplify representation when you only have one to choose from if len(parents) + len(parents_any) == 3: @@ -913,25 +936,35 @@ def set_hostname_and_reset_connections(self, new_hostname: str) -> None: self.connections.disconnect_all_peers(force=True) -class ParentTxs(NamedTuple): +@dataclass(slots=True, frozen=True, kw_only=True) +class ParentTxs: """ Tuple where the `must_include` hash, when present (at most 1), must be included in a pair, and a list of hashes where any of them can be included. This is done in order to make sure that when there is only one tx tip, it is included. """ max_timestamp: int can_include: list[VertexId] - must_include: list[VertexId] + must_include: tuple[()] | tuple[VertexId] + + def __post_init__(self) -> None: + assert len(self.must_include) <= 1 + if self.must_include: + assert self.must_include[0] not in self.can_include + + @staticmethod + def from_txs(*, can_include: list[Transaction], must_include: tuple[()] | tuple[Transaction]) -> 'ParentTxs': + assert len(can_include) + len(must_include) >= 2 + return ParentTxs( + max_timestamp=max(tx.timestamp for tx in (*can_include, *must_include)), + can_include=list(tx.hash for tx in can_include), + must_include=(must_include[0].hash,) if must_include else (), + ) def get_random_parents(self, rng: Random) -> tuple[VertexId, VertexId]: """ Get parents from self.parents plus a random choice from self.parents_any to make it 3 in total. Using tuple as return type to make it explicit that the length is always 2. """ - assert len(self.must_include) <= 1 fill = rng.ordered_sample(self.can_include, 2 - len(self.must_include)) - p1, p2 = self.must_include[:] + fill + p1, p2 = self.must_include + tuple(fill) return p1, p2 - - def get_all_tips(self) -> list[VertexId]: - """All generated "tips", can_include + must_include.""" - return self.must_include + self.can_include diff --git a/hathor/transaction/base_transaction.py b/hathor/transaction/base_transaction.py index 8d5786460..831970b13 100644 --- a/hathor/transaction/base_transaction.py +++ b/hathor/transaction/base_transaction.py @@ -52,6 +52,7 @@ from _hashlib import HASH from hathor.conf.settings import HathorSettings + from hathor.transaction import Transaction from hathor.transaction.storage import TransactionStorage # noqa: F401 logger = get_logger() @@ -486,10 +487,15 @@ def get_all_dependencies(self) -> set[bytes]: """Set of all tx-hashes needed to fully validate this tx, including parent blocks/txs and inputs.""" return set(chain(self.parents, (i.tx_id for i in self.inputs))) - def get_tx_parents(self) -> set[bytes]: + def get_tx_parents_ids(self) -> set[VertexId]: """Set of parent tx hashes, typically used for syncing transactions.""" return set(self.parents[1:] if self.is_block else self.parents) + def get_tx_parents(self) -> set[Transaction]: + """Set of parent txs.""" + assert self.storage is not None + return set(self.storage.get_tx(parent_id) for parent_id in self.get_tx_parents_ids()) + def get_related_addresses(self) -> set[str]: """ Return a set of addresses collected from tx's inputs and outputs. """ diff --git a/hathor/transaction/static_metadata.py b/hathor/transaction/static_metadata.py index 2e61ea17d..0957baaca 100644 --- a/hathor/transaction/static_metadata.py +++ b/hathor/transaction/static_metadata.py @@ -122,7 +122,7 @@ def _calculate_min_height(block: 'Block', vertex_getter: Callable[[VertexId], 'B """ # maximum min-height of any parent tx min_height = 0 - for tx_hash in block.get_tx_parents(): + for tx_hash in block.get_tx_parents_ids(): tx = vertex_getter(tx_hash) min_height = max(min_height, tx.static_metadata.min_height) @@ -227,7 +227,7 @@ def _calculate_inherited_min_height( ) -> int: """ Calculates min height inherited from any input or parent""" min_height = 0 - iter_parents = tx.get_tx_parents() + iter_parents = tx.get_tx_parents_ids() iter_inputs = (tx_input.tx_id for tx_input in tx.inputs) for vertex_id in chain(iter_parents, iter_inputs): vertex = vertex_getter(vertex_id) diff --git a/hathor/transaction/storage/transaction_storage.py b/hathor/transaction/storage/transaction_storage.py index ba4b0234b..7b41e5202 100644 --- a/hathor/transaction/storage/transaction_storage.py +++ b/hathor/transaction/storage/transaction_storage.py @@ -551,6 +551,12 @@ def get_transaction(self, hash_bytes: bytes) -> BaseTransaction: self.post_get_validation(tx) return tx + def get_tx(self, vertex_id: VertexId) -> Transaction: + """Return a Transaction.""" + tx = self.get_transaction(vertex_id) + assert isinstance(tx, Transaction) + return tx + def get_token_creation_transaction(self, hash_bytes: bytes) -> TokenCreationTransaction: """Acquire the lock and get the token creation transaction with hash `hash_bytes`. diff --git a/tests/others/test_init_manager.py b/tests/others/test_init_manager.py index 71b844abf..2e315964f 100644 --- a/tests/others/test_init_manager.py +++ b/tests/others/test_init_manager.py @@ -213,6 +213,7 @@ def test_init_not_voided_tips(self): assert set(tx.hash for tx in manager.tx_storage.get_all_transactions()) == self.all_hashes # make sure none of its tx tips are voided - all_tips = manager.generate_parent_txs(None).get_all_tips() + parent_txs = manager.generate_parent_txs(None) + all_tips = parent_txs.can_include + list(parent_txs.must_include) iter_tips_meta = map(manager.tx_storage.get_metadata, all_tips) self.assertFalse(any(tx_meta.voided_by for tx_meta in iter_tips_meta)) diff --git a/tests/p2p/test_sync_v2.py b/tests/p2p/test_sync_v2.py index ebe76bb27..bbaf30d0d 100644 --- a/tests/p2p/test_sync_v2.py +++ b/tests/p2p/test_sync_v2.py @@ -397,7 +397,7 @@ def test_multiple_unexpected_txs(self) -> None: # make up some transactions that the node isn't expecting best_block = manager1.tx_storage.get_best_block() - existing_tx = manager1.tx_storage.get_transaction(list(best_block.get_tx_parents())[0]) + existing_tx = manager1.tx_storage.get_transaction(list(best_block.get_tx_parents_ids())[0]) fake_txs = [] for i in range(3): fake_tx = existing_tx.clone() diff --git a/tests/tx/test_generate_tx_parents.py b/tests/tx/test_generate_tx_parents.py new file mode 100644 index 000000000..06af82795 --- /dev/null +++ b/tests/tx/test_generate_tx_parents.py @@ -0,0 +1,52 @@ +# Copyright 2025 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.transaction import Block, Transaction +from tests import unittest +from tests.dag_builder.builder import TestDAGBuilder + + +class GenerateTxParentsTestCase(unittest.TestCase): + def setUp(self) -> None: + super().setUp() + self.manager = self.create_peer(network='unittests') + self.dag_builder = TestDAGBuilder.from_manager(self.manager) + + def test_some_confirmed_txs(self) -> None: + artifacts = self.dag_builder.build_from_str(''' + blockchain genesis b[1..11] + b10 < dummy + + dummy < tx1 < tx2 < tx3 < tx4 < tx5 + + dummy <-- tx2 <-- b11 + tx3 <-- tx4 <-- tx5 <-- b11 + ''') + + b11, = artifacts.get_typed_vertices(('b11',), Block) + dummy, tx1, tx2, tx3, tx4, tx5 = artifacts.get_typed_vertices( + ('dummy', 'tx1', 'tx2', 'tx3', 'tx4', 'tx5'), Transaction + ) + + artifacts.propagate_with(self.manager) + assert tx1.get_metadata().first_block is None + assert tx2.get_metadata().first_block == b11.hash + assert tx3.get_metadata().first_block == b11.hash + assert tx4.get_metadata().first_block == b11.hash + assert tx5.get_metadata().first_block == b11.hash + assert dummy.get_metadata().first_block == b11.hash + + parent_txs = self.manager.generate_parent_txs(timestamp=None) + assert parent_txs.must_include == (tx1.hash,) + assert parent_txs.can_include == [tx2.hash, tx5.hash] diff --git a/tests/tx/test_tips.py b/tests/tx/test_tips.py index b4520bbba..0c6e231eb 100644 --- a/tests/tx/test_tips.py +++ b/tests/tx/test_tips.py @@ -113,8 +113,9 @@ def test_choose_tips(self): self.assertTrue(set(b2.parents).issubset(set([tx3.hash] + [reward_blocks[-1].hash] + tx3.parents))) tx4 = add_new_transactions(self.manager, 1, advance_clock=1)[0] - # tx4 had no tip, so the parents will be the last block parents - self.assertCountEqual(set(tx4.parents), set(b2.parents[1:])) + # tx4 had no tip, so the parents will be tx3 and one of tx3 parents + self.assertTrue(tx3.hash in tx4.parents) + self.assertTrue(set(tx4.parents).issubset(set([tx3.hash] + tx3.parents))) # Then tx4 will become a tip self.assertCountEqual(self.get_tips(), set([tx4.hash]))