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
7 changes: 7 additions & 0 deletions hathor/cli/events_simulator/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,15 @@ def simulate_invalid_mempool_transaction(simulator: 'Simulator', manager: 'Hatho
blocks = add_new_blocks(manager, settings.REWARD_SPEND_MIN_BLOCKS + 1)
simulator.run(60)

balance_per_address = manager.wallet.get_balance_per_address(settings.HATHOR_TOKEN_UID)
assert balance_per_address[address] == 6400
tx = gen_new_tx(manager, address, 1000)
tx.weight = manager.daa.minimum_tx_weight(tx)
tx.update_hash()
assert manager.propagate_tx(tx, fails_silently=False)
simulator.run(60)
balance_per_address = manager.wallet.get_balance_per_address(settings.HATHOR_TOKEN_UID)
assert balance_per_address[address] == 1000

# re-org: replace last two blocks with one block, new height will be just one short of enough
block_to_replace = blocks[-2]
Expand All @@ -178,6 +182,9 @@ def simulate_invalid_mempool_transaction(simulator: 'Simulator', manager: 'Hatho
# the transaction should have been removed from the mempool and the storage after the re-org
assert tx not in manager.tx_storage.iter_mempool_from_best_index()
assert not manager.tx_storage.transaction_exists(tx.hash)
assert bool(tx.get_metadata().voided_by)
balance_per_address = manager.wallet.get_balance_per_address(settings.HATHOR_TOKEN_UID)
assert balance_per_address[address] == 6400


def simulate_empty_script(simulator: 'Simulator', manager: 'HathorManager') -> None:
Expand Down
1 change: 1 addition & 0 deletions hathor/consensus/consensus.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ def _remove_transactions(
for tx in txs:
tx_meta = tx.get_metadata()
assert not tx_meta.validation.is_checkpoint()
assert bool(tx_meta.voided_by), 'removed txs must be voided'
for parent in set(tx.parents) - txset:
parents_to_update[parent].append(tx.hash)
dangling_children.update(set(tx_meta.children) - txset)
Expand Down
2 changes: 1 addition & 1 deletion hathor/event/model/event_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ class TxDataWithoutMeta(BaseEventData, extra=Extra.ignore):
@classmethod
def from_event_arguments(cls, args: EventArguments) -> Self:
from hathor.transaction.resources.transaction import get_tx_extra_data
tx_extra_data_json = get_tx_extra_data(args.tx, detail_tokens=False)
tx_extra_data_json = get_tx_extra_data(args.tx, detail_tokens=False, force_reload_metadata=False)
tx_json = tx_extra_data_json['tx']
meta_json = tx_extra_data_json['meta']
tx_json['metadata'] = meta_json
Expand Down
7 changes: 7 additions & 0 deletions hathor/wallet/base_wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def __init__(self, directory: str = './', pubsub: Optional[PubSubManager] = None

self.pubsub_events = [
HathorEvents.CONSENSUS_TX_UPDATE,
HathorEvents.CONSENSUS_TX_REMOVED,
]

if reactor is None:
Expand Down Expand Up @@ -176,6 +177,12 @@ def handle_publish(self, key: HathorEvents, args: EventArguments) -> None:
data = args.__dict__
if key == HathorEvents.CONSENSUS_TX_UPDATE:
self.on_tx_update(data['tx'])
elif key == HathorEvents.CONSENSUS_TX_REMOVED:
# we use the same method as above because a removed tx is also voided
tx = data['tx']
assert isinstance(tx, Transaction)
assert bool(tx.get_metadata().voided_by)
self.on_tx_update(tx)
else:
raise NotImplementedError

Expand Down
56 changes: 34 additions & 22 deletions tests/tx/test_reward_lock.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import pytest

from hathor.crypto.util import get_address_from_public_key
from hathor.crypto.util import get_address_b58_from_bytes, get_address_from_public_key
from hathor.exception import InvalidNewTransaction
from hathor.manager import HathorManager
from hathor.simulator.utils import add_new_blocks
from hathor.transaction import Transaction, TxInput, TxOutput
from hathor.transaction import Block, Transaction, TxInput, TxOutput
from hathor.transaction.exceptions import RewardLocked
from hathor.transaction.scripts import P2PKH
from hathor.transaction.storage import TransactionMemoryStorage
Expand All @@ -15,7 +16,7 @@
class BaseTransactionTest(unittest.TestCase):
__test__ = False

def setUp(self):
def setUp(self) -> None:
super().setUp()
self.wallet = Wallet()

Expand All @@ -32,7 +33,7 @@ def setUp(self):
blocks = add_blocks_unlock_reward(self.manager)
self.last_block = blocks[-1]

def _add_reward_block(self):
def _add_reward_block(self) -> tuple[Block, int]:
reward_block = self.manager.generate_mining_block(
address=get_address_from_public_key(self.genesis_public_key)
)
Expand All @@ -42,9 +43,10 @@ def _add_reward_block(self):
unlock_height = reward_block.static_metadata.height + self._settings.REWARD_SPEND_MIN_BLOCKS + 1
return reward_block, unlock_height

def _spend_reward_tx(self, manager, reward_block):
def _spend_reward_tx(self, manager: HathorManager, reward_block: Block) -> tuple[Transaction, str]:
value = reward_block.outputs[0].value
address = get_address_from_public_key(self.genesis_public_key)
assert manager.wallet is not None
address = manager.wallet.get_unused_address_bytes()
script = P2PKH.create_output_script(address)
input_ = TxInput(reward_block.hash, 0, b'')
output = TxOutput(value, script)
Expand All @@ -62,26 +64,26 @@ def _spend_reward_tx(self, manager, reward_block):
self.manager.cpu_mining_service.resolve(tx)
tx.update_initial_metadata(save=False)
tx.init_static_metadata_from_storage(self._settings, self.tx_storage)
return tx
return tx, get_address_b58_from_bytes(address)

def test_classic_reward_lock(self):
def test_classic_reward_lock(self) -> None:
# add block with a reward we can spend
reward_block, unlock_height = self._add_reward_block()

# reward cannot be spent while not enough blocks are added
for _ in range(self._settings.REWARD_SPEND_MIN_BLOCKS):
tx = self._spend_reward_tx(self.manager, reward_block)
tx, _ = self._spend_reward_tx(self.manager, reward_block)
self.assertEqual(tx.static_metadata.min_height, unlock_height)
with self.assertRaises(RewardLocked):
self.manager.verification_service.verify(tx)
add_new_blocks(self.manager, 1, advance_clock=1)

# now it should be spendable
tx = self._spend_reward_tx(self.manager, reward_block)
tx, _ = self._spend_reward_tx(self.manager, reward_block)
self.assertEqual(tx.static_metadata.min_height, unlock_height)
self.assertTrue(self.manager.propagate_tx(tx, fails_silently=False))

def test_block_with_not_enough_height(self):
def test_block_with_not_enough_height(self) -> None:
# add block with a reward we can spend
reward_block, unlock_height = self._add_reward_block()

Expand All @@ -91,7 +93,7 @@ def test_block_with_not_enough_height(self):
# add tx bypassing reward-lock verification
# XXX: this situation is impossible in practice, but we force it to test that when a block tries to confirm a
# transaction before it can the RewardLocked exception is raised
tx = self._spend_reward_tx(self.manager, reward_block)
tx, _ = self._spend_reward_tx(self.manager, reward_block)
self.assertEqual(tx.static_metadata.min_height, unlock_height)
self.assertTrue(self.manager.on_new_tx(tx, fails_silently=False, reject_locked_reward=False))

Expand All @@ -105,22 +107,22 @@ def test_block_with_not_enough_height(self):
all_blocks = [vertex for vertex in self.manager.tx_storage.get_all_transactions() if vertex.is_block]
assert len(all_blocks) == 2 * self._settings.REWARD_SPEND_MIN_BLOCKS + 1

def test_block_with_enough_height(self):
def test_block_with_enough_height(self) -> None:
# add block with a reward we can spend
reward_block, unlock_height = self._add_reward_block()

# add just enough blocks
add_new_blocks(self.manager, self._settings.REWARD_SPEND_MIN_BLOCKS, advance_clock=1)

# add tx that spends the reward
tx = self._spend_reward_tx(self.manager, reward_block)
tx, _ = self._spend_reward_tx(self.manager, reward_block)
self.assertEqual(tx.static_metadata.min_height, unlock_height)
self.assertTrue(self.manager.on_new_tx(tx, fails_silently=False))

# new block will be able to confirm it
add_new_blocks(self.manager, 1, advance_clock=1)

def test_mempool_tx_with_not_enough_height(self):
def test_mempool_tx_with_not_enough_height(self) -> None:
from hathor.exception import InvalidNewTransaction

# add block with a reward we can spend
Expand All @@ -130,36 +132,40 @@ def test_mempool_tx_with_not_enough_height(self):
add_new_blocks(self.manager, self._settings.REWARD_SPEND_MIN_BLOCKS - 1, advance_clock=1)

# add tx to mempool, must fail reward-lock verification
tx = self._spend_reward_tx(self.manager, reward_block)
tx, _ = self._spend_reward_tx(self.manager, reward_block)
self.assertEqual(tx.static_metadata.min_height, unlock_height)
with self.assertRaises(RewardLocked):
self.manager.verification_service.verify(tx)
with self.assertRaises(InvalidNewTransaction):
self.assertTrue(self.manager.on_new_tx(tx, fails_silently=False))

def test_mempool_tx_with_enough_height(self):
def test_mempool_tx_with_enough_height(self) -> None:
# add block with a reward we can spend
reward_block, unlock_height = self._add_reward_block()

# add just enough blocks
add_new_blocks(self.manager, self._settings.REWARD_SPEND_MIN_BLOCKS, advance_clock=1)

# add tx that spends the reward, must not fail
tx = self._spend_reward_tx(self.manager, reward_block)
tx, _ = self._spend_reward_tx(self.manager, reward_block)
self.assertEqual(tx.static_metadata.min_height, unlock_height)
self.assertTrue(self.manager.on_new_tx(tx, fails_silently=False))

def test_mempool_tx_invalid_after_reorg(self):
def test_mempool_tx_invalid_after_reorg(self) -> None:
# add block with a reward we can spend
reward_block, unlock_height = self._add_reward_block()

# add just enough blocks
blocks = add_new_blocks(self.manager, self._settings.REWARD_SPEND_MIN_BLOCKS, advance_clock=1)

# add tx that spends the reward, must not fail
tx = self._spend_reward_tx(self.manager, reward_block)
tx, tx_address = self._spend_reward_tx(self.manager, reward_block)
balance_per_address = self.manager.wallet.get_balance_per_address(self._settings.HATHOR_TOKEN_UID)
assert tx_address not in balance_per_address
self.assertEqual(tx.static_metadata.min_height, unlock_height)
self.assertTrue(self.manager.on_new_tx(tx, fails_silently=False))
balance_per_address = self.manager.wallet.get_balance_per_address(self._settings.HATHOR_TOKEN_UID)
assert balance_per_address[tx_address] == 6400

# re-org: replace last two blocks with one block, new height will be just one short of enough
block_to_replace = blocks[-2]
Expand All @@ -168,6 +174,7 @@ def test_mempool_tx_invalid_after_reorg(self):
b0.weight = 10
self.manager.cpu_mining_service.resolve(b0)
self.manager.propagate_tx(b0, fails_silently=False)
self.clock.advance(1)

# now the new tx should not pass verification considering the reward lock
with self.assertRaises(RewardLocked):
Expand All @@ -179,6 +186,7 @@ def test_mempool_tx_invalid_after_reorg(self):
# additionally the transaction should have been marked as invalid and removed from the storage after the re-org
self.assertTrue(tx.get_metadata().validation.is_invalid())
self.assertFalse(self.manager.tx_storage.transaction_exists(tx.hash))
self.assertTrue(bool(tx.get_metadata().voided_by))

# assert that the tx has been removed from its dependencies' metadata
for parent_id in tx.parents:
Expand All @@ -191,8 +199,12 @@ def test_mempool_tx_invalid_after_reorg(self):
assert len(spent_outputs) == 1
assert tx.hash not in spent_outputs[0]

# the balance for the tx_address must have been removed
balance_per_address = self.manager.wallet.get_balance_per_address(self._settings.HATHOR_TOKEN_UID)
assert tx_address not in balance_per_address

@pytest.mark.xfail(reason='this is no longer the case, timestamp will not matter', strict=True)
def test_classic_reward_lock_timestamp_expected_to_fail(self):
def test_classic_reward_lock_timestamp_expected_to_fail(self) -> None:
# add block with a reward we can spend
reward_block, unlock_height = self._add_reward_block()

Expand All @@ -201,7 +213,7 @@ def test_classic_reward_lock_timestamp_expected_to_fail(self):

# tx timestamp is equal to the block that unlock the spent rewards. It should
# be greater, so it'll fail
tx = self._spend_reward_tx(self.manager, reward_block)
tx, _ = self._spend_reward_tx(self.manager, reward_block)
tx.timestamp = blocks[-1].timestamp
self.manager.cpu_mining_service.resolve(tx)
self.assertEqual(tx.static_metadata.min_height, unlock_height)
Expand Down