diff --git a/hathor/cli/events_simulator/scenario.py b/hathor/cli/events_simulator/scenario.py index 460268c2f..7ee5b7917 100644 --- a/hathor/cli/events_simulator/scenario.py +++ b/hathor/cli/events_simulator/scenario.py @@ -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] @@ -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: diff --git a/hathor/consensus/consensus.py b/hathor/consensus/consensus.py index 8bdbb4e4a..43a70e7f8 100644 --- a/hathor/consensus/consensus.py +++ b/hathor/consensus/consensus.py @@ -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) diff --git a/hathor/event/model/event_data.py b/hathor/event/model/event_data.py index 3ee281bd8..a24ceca1c 100644 --- a/hathor/event/model/event_data.py +++ b/hathor/event/model/event_data.py @@ -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 diff --git a/hathor/wallet/base_wallet.py b/hathor/wallet/base_wallet.py index 7b38dfa12..000c8a100 100644 --- a/hathor/wallet/base_wallet.py +++ b/hathor/wallet/base_wallet.py @@ -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: @@ -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 diff --git a/tests/tx/test_reward_lock.py b/tests/tx/test_reward_lock.py index 99b9678a8..1a56d7e6a 100644 --- a/tests/tx/test_reward_lock.py +++ b/tests/tx/test_reward_lock.py @@ -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 @@ -15,7 +16,7 @@ class BaseTransactionTest(unittest.TestCase): __test__ = False - def setUp(self): + def setUp(self) -> None: super().setUp() self.wallet = Wallet() @@ -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) ) @@ -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) @@ -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() @@ -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)) @@ -105,7 +107,7 @@ 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() @@ -113,14 +115,14 @@ def test_block_with_enough_height(self): 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 @@ -130,14 +132,14 @@ 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() @@ -145,11 +147,11 @@ def test_mempool_tx_with_enough_height(self): 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() @@ -157,9 +159,13 @@ def test_mempool_tx_invalid_after_reorg(self): 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] @@ -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): @@ -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: @@ -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() @@ -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)