Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 57 additions & 24 deletions hathor/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
8 changes: 7 additions & 1 deletion hathor/transaction/base_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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.
"""
Expand Down
4 changes: 2 additions & 2 deletions hathor/transaction/static_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions hathor/transaction/storage/transaction_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
3 changes: 2 additions & 1 deletion tests/others/test_init_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
2 changes: 1 addition & 1 deletion tests/p2p/test_sync_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
52 changes: 52 additions & 0 deletions tests/tx/test_generate_tx_parents.py
Original file line number Diff line number Diff line change
@@ -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]
5 changes: 3 additions & 2 deletions tests/tx/test_tips.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]))

Expand Down
Loading