diff --git a/safe_locking_service/locking_events/tests/mocks/mocks_locking_events_indexer.py b/safe_locking_service/locking_events/tests/mocks/mocks_locking_events_indexer.py new file mode 100644 index 0000000..22eae14 --- /dev/null +++ b/safe_locking_service/locking_events/tests/mocks/mocks_locking_events_indexer.py @@ -0,0 +1,188 @@ +from hexbytes import HexBytes +from web3.datastructures import AttributeDict +from web3.types import LogReceipt + +invalid_topic_event_mock: LogReceipt = AttributeDict( + { + "address": "0x286A5d99174B45A182C28227760494bb730F7996", + "blockHash": HexBytes( + "0x6e1f11c92f838e977a6d4faa739079c59e9a5568ba1a690dd433d625cf824855" + ), + "blockNumber": 1523, + "data": HexBytes( + "0x0000000000000000000000000000000000000000000000000000000000000064" + ), + "logIndex": 2, + "removed": False, + "topics": [ + HexBytes( + "0xe87ef84d870121e4ffffffffffffffff3cb00a0c072f0d771f78ea368815009f" + ), + HexBytes( + "0x00000000000000000000000022d491bde2303f2f43325b2108d26f1eaba1e32a" + ), + ], + "transactionHash": HexBytes( + "0x396bccc79fca90671f6719d9572b9fda55b1985041e048faa6ba7da2b11d318b" + ), + "transactionIndex": 0, + } +) + +valid_lock_event_mock: LogReceipt = AttributeDict( + { + "address": "0x286A5d99174B45A182C28227760494bb730F7996", + "blockHash": HexBytes( + "0x6e1f11c92f838e977a6d4faa739079c59e9a5568ba1a690dd433d625cf824855" + ), + "blockNumber": 1523, + "data": HexBytes( + "0x0000000000000000000000000000000000000000000000000000000000000064" + ), + "logIndex": 2, + "removed": False, + "topics": [ + HexBytes( + "0xe87ef84d870121e49051dda6f8d297713cb00a0c072c0d771f78ea368815009f" + ), + HexBytes( + "0x00000000000000000000000022d491bde2303f2f43325b2108d26f1eaba1e32a" + ), + ], + "transactionHash": HexBytes( + "0x396bccc79fca90671f6719d9572b9fda55b1985041e048faa6ba7da2b11d318b" + ), + "transactionIndex": 0, + } +) + +invalid_lock_event_mock: LogReceipt = AttributeDict( + { + "address": "0x286A5d99174B45A182C28227760494bb730F7996", + "blockHash": HexBytes( + "0x6e1f11c92f838e977a6d4faa739079c59e9a5568ba1a690dd433d625cf824855" + ), + "blockNumber": 1523, + "data": HexBytes("0x"), + "logIndex": 2, + "removed": False, + "topics": [ + HexBytes( + "0xe87ef84d870121e49051dda6f8d297713cb00a0c072c0d771f78ea368815009f" + ), + HexBytes("0x"), + ], + "transactionHash": HexBytes( + "0x396bccc79fca90671f6719d9572b9fda55b1985041e048faa6ba7da2b11d318b" + ), + "transactionIndex": 0, + } +) + +valid_unlock_event_mock: LogReceipt = AttributeDict( + { + "address": "0x286A5d99174B45A182C28227760494bb730F7996", + "blockHash": HexBytes( + "0x4320d20c35235e62e8c40382bc5965561d49a194b868ab1460322a5445047c11" + ), + "blockNumber": 1533, + "data": HexBytes( + "0x000000000000000000000000000000000000000000000000000000000000000a" + ), + "logIndex": 0, + "removed": False, + "topics": [ + HexBytes( + "0x1bd2aac5b8fbf8aacc4b880dbd7230d62fc208b7c317a6f219a23703a80262c8" + ), + HexBytes( + "0x00000000000000000000000022d491bde2303f2f43325b2108d26f1eaba1e32b" + ), + HexBytes( + "0x0000000000000000000000000000000000000000000000000000000000000009" + ), + ], + "transactionHash": HexBytes( + "0x6a5ee672171a64e7811a35900115bea47dc4db835d89f0a10be1309102e2c466" + ), + "transactionIndex": 0, + } +) + +invalid_unlock_event_mock: LogReceipt = AttributeDict( + { + "address": "0x286A5d99174B45A182C28227760494bb730F7996", + "blockHash": HexBytes( + "0x4320d20c35235e62e8c40382bc5965561d49a194b868ab1460322a5445047c11" + ), + "blockNumber": 1533, + "data": HexBytes("0x"), + "logIndex": 0, + "removed": False, + "topics": [ + HexBytes( + "0x1bd2aac5b8fbf8aacc4b880dbd7230d62fc208b7c317a6f219a23703a80262c8" + ), + HexBytes("0x"), + HexBytes("0x"), + ], + "transactionHash": HexBytes( + "0x6a5ee672171a64e7811a35900115bea47dc4db835d89f0a10be1309102e2c466" + ), + "transactionIndex": 0, + } +) + +valid_withdrawn_event_mock: LogReceipt = AttributeDict( + { + "address": "0x286A5d99174B45A182C28227760494bb730F7996", + "blockHash": HexBytes( + "0xae7b647d58d989545a93a7748470fd957c4897417b3da3a9d7aaaf44ed61a286" + ), + "blockNumber": 1535, + "data": HexBytes( + "0x000000000000000000000000000000000000000000000000000000000000000a" + ), + "logIndex": 2, + "removed": False, + "topics": [ + HexBytes( + "0xd37890a72e2e5df42bee9bd278d8b896297dffeb08b2b2503f726cfb2e3b9826" + ), + HexBytes( + "0x00000000000000000000000022d491bde2303f2f43325b2108d26f1eaba1e32b" + ), + HexBytes( + "0x0000000000000000000000000000000000000000000000000000000000000002" + ), + ], + "transactionHash": HexBytes( + "0x4ff4c69ee6267e96191f5cc12096dfca19de772461ca0eaa69dec544ca2a9b47" + ), + "transactionIndex": 0, + } +) + +invalid_withdrawn_event_mock: LogReceipt = AttributeDict( + { + "address": "0x286A5d99174B45A182C28227760494bb730F7996", + "blockHash": HexBytes( + "0xae7b647d58d989545a93a7748470fd957c4897417b3da3a9d7aaaf44ed61a286" + ), + "blockNumber": 1535, + "data": HexBytes("0x"), + "logIndex": 2, + "removed": False, + "topics": [ + HexBytes( + "0xd37890a72e2e5df42bee9bd278d8b896297dffeb08b2b2503f726cfb2e3b9826" + ), + HexBytes("0x"), + HexBytes("0x"), + ], + "transactionHash": HexBytes( + "0x4ff4c69ee6267e96191f5cc12096dfca19de772461ca0eaa69dec544ca2a9b47" + ), + "transactionIndex": 0, + } +) diff --git a/safe_locking_service/locking_events/tests/test_locking_events_indexer.py b/safe_locking_service/locking_events/tests/test_locking_events_indexer.py index a90fc1c..d296728 100644 --- a/safe_locking_service/locking_events/tests/test_locking_events_indexer.py +++ b/safe_locking_service/locking_events/tests/test_locking_events_indexer.py @@ -1,6 +1,8 @@ from django.db.models import Sum from django.test import TestCase +from hexbytes import HexBytes + from gnosis.eth.tests.ethereum_test_case import EthereumTestCaseMixin from ..contracts.locking_contract import deploy_locking_contract @@ -8,17 +10,42 @@ SafeLockingEventsIndexer, get_safe_locking_event_indexer, ) -from ..models import EthereumTx, LockEvent, StatusEventsIndexer, UnlockEvent -from .utils import erc20_approve, locking_contract_lock, locking_contract_unlock +from ..models import ( + EthereumTx, + LockEvent, + StatusEventsIndexer, + UnlockEvent, + WithdrawnEvent, +) +from .mocks.mocks_locking_events_indexer import ( + invalid_lock_event_mock, + invalid_topic_event_mock, + invalid_unlock_event_mock, + invalid_withdrawn_event_mock, + valid_lock_event_mock, + valid_unlock_event_mock, + valid_withdrawn_event_mock, +) +from .utils import ( + erc20_approve, + increment_chain_time, + locking_contract_lock, + locking_contract_unlock, + locking_contract_withdraw, +) class TestLockingEventsIndexer(EthereumTestCaseMixin, TestCase): def setUp(self) -> None: + self.cooldown_period = 2 account = self.ethereum_test_account amount = 10000000 self.erc20_contract = self.deploy_example_erc20(amount, account.address) self.locking_contract = deploy_locking_contract( - self.ethereum_client, account, self.erc20_contract.address + self.ethereum_client, + account, + self.erc20_contract.address, + self.cooldown_period, ) self.locking_contract_address = self.locking_contract.address self.assertIsNotNone(self.locking_contract.address) @@ -80,7 +107,7 @@ def test_index_unlock_events(self): self.assertEqual(EthereumTx.objects.count(), 0) self.assertEqual(UnlockEvent.objects.count(), 0) locking_contract_lock( - self.ethereum_client.w3, account, self.locking_contract, 100 + self.ethereum_client.w3, account, self.locking_contract, lock_amount ) for i in range(0, 10): locking_contract_unlock( @@ -95,5 +122,165 @@ def test_index_unlock_events(self): UnlockEvent.objects.filter(holder=account.address).aggregate( total=Sum("amount") )["total"], - 100, + lock_amount, + ) + + def test_index_withdrawn_events(self): + account = self.ethereum_test_account + lock_amount = 100 + erc20_approve( + self.ethereum_client.w3, + account, + self.erc20_contract, + self.locking_contract.address, + lock_amount, + ) + locking_events_indexer = SafeLockingEventsIndexer(self.locking_contract_address) + locking_events_indexer.index_until_last_chain_block() + self.assertEqual(EthereumTx.objects.count(), 0) + self.assertEqual(UnlockEvent.objects.count(), 0) + locking_contract_lock( + self.ethereum_client.w3, account, self.locking_contract, lock_amount + ) + for i in range(0, 10): + locking_contract_unlock( + self.ethereum_client.w3, account, self.locking_contract, 10 + ) + locking_events_indexer.index_until_last_chain_block() + increment_chain_time(self.ethereum_client.w3, self.cooldown_period) + locking_contract_withdraw( + self.ethereum_client.w3, account, self.locking_contract, 5 + ) + locking_events_indexer.index_until_last_chain_block() + # 1 Lock, 10 Unlock and 1 Withdraw + self.assertEqual(EthereumTx.objects.count(), 12) + self.assertEqual(LockEvent.objects.filter(holder=account.address).count(), 1) + self.assertEqual(UnlockEvent.objects.filter(holder=account.address).count(), 10) + # 5 withdrawn with amount of 10 by previous unlock event. + self.assertEqual( + WithdrawnEvent.objects.filter(holder=account.address).count(), 5 + ) + + self.assertEqual( + WithdrawnEvent.objects.filter(holder=account.address).aggregate( + total=Sum("amount") + )["total"], + 50, + ) + + def test_event_decoding(self): + locking_events_indexer = SafeLockingEventsIndexer(self.locking_contract_address) + + self.assertRaises( + KeyError, locking_events_indexer.decode_event, invalid_topic_event_mock + ) + + data_lock_event = locking_events_indexer.decode_event(valid_lock_event_mock) + self.assertEqual(data_lock_event.get("event"), "Locked") + self.assertEqual( + data_lock_event.get("args").get("holder"), + "0x22D491bde2303f2F43325b2108d26F1EaBA1E32A", + ) + self.assertEqual(data_lock_event.get("args").get("amount"), 100) + + invalid_data_lock_event = locking_events_indexer.decode_event( + invalid_lock_event_mock + ) + self.assertIsNone(invalid_data_lock_event) + + data_unlock_event = locking_events_indexer.decode_event(valid_unlock_event_mock) + self.assertEqual(data_unlock_event.get("event"), "Unlocked") + self.assertEqual( + data_unlock_event.get("args").get("holder"), + "0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b", + ) + self.assertEqual(data_unlock_event.get("args").get("index"), 9) + self.assertEqual(data_unlock_event.get("args").get("amount"), 10) + + invalid_data_unlock_event = locking_events_indexer.decode_event( + invalid_unlock_event_mock + ) + self.assertIsNone(invalid_data_unlock_event) + + data_withdrawn_event = locking_events_indexer.decode_event( + valid_withdrawn_event_mock + ) + self.assertEqual(data_withdrawn_event.get("event"), "Withdrawn") + self.assertEqual( + data_withdrawn_event.get("args").get("holder"), + "0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b", + ) + self.assertEqual(data_withdrawn_event.get("args").get("index"), 2) + self.assertEqual(data_withdrawn_event.get("args").get("amount"), 10) + + invalid_data_withdrawn_event = locking_events_indexer.decode_event( + invalid_withdrawn_event_mock + ) + self.assertIsNone(invalid_data_withdrawn_event) + + def test_element_already_processed_checker(self): + locking_events_indexer = SafeLockingEventsIndexer(self.locking_contract_address) + + processed_element_cache = ( + locking_events_indexer.element_already_processed_checker._processed_element_cache + ) + self.assertEqual(len(processed_element_cache.keys()), 0) + + account = self.ethereum_test_account + lock_amount = 100 + erc20_approve( + self.ethereum_client.w3, + account, + self.erc20_contract, + self.locking_contract.address, + lock_amount, + ) + locking_events_indexer = SafeLockingEventsIndexer(self.locking_contract_address) + locking_events_indexer.index_until_last_chain_block() + lock_tx = locking_contract_lock( + self.ethereum_client.w3, account, self.locking_contract, lock_amount + ) + locking_events_indexer.index_until_last_chain_block() + + processed_element_cache = ( + locking_events_indexer.element_already_processed_checker._processed_element_cache + ) + processed_keys_lock_event = [ + event["transactionHash"] + event["blockHash"] + HexBytes(event["logIndex"]) + for event in lock_tx["logs"] + if event["topics"][0].hex() + in locking_events_indexer.events_to_listen.keys() + ] + + self.assertListEqual( + processed_keys_lock_event, list(processed_element_cache.keys()) + ) + self.assertEqual( + len(locking_events_indexer.get_unprocessed_events(lock_tx["logs"])), + len(lock_tx["logs"]) - len(processed_keys_lock_event), + ) + self.assertEqual(len(processed_element_cache.keys()), 1) + + unlock_tx = locking_contract_unlock( + self.ethereum_client.w3, account, self.locking_contract, 100 + ) + + locking_events_indexer.index_until_last_chain_block() + + processed_element_cache = ( + locking_events_indexer.element_already_processed_checker._processed_element_cache + ) + processed_keys_unlock_event = [ + event["transactionHash"] + event["blockHash"] + HexBytes(event["logIndex"]) + for event in unlock_tx["logs"] + if event["topics"][0].hex() + in locking_events_indexer.events_to_listen.keys() + ] + + processed_keys_all_events = ( + processed_keys_lock_event + processed_keys_unlock_event + ) + self.assertListEqual( + processed_keys_all_events, list(processed_element_cache.keys()) ) + self.assertEqual(len(processed_element_cache.keys()), 2) diff --git a/safe_locking_service/locking_events/tests/utils.py b/safe_locking_service/locking_events/tests/utils.py index 9b7c6e2..b57ceef 100644 --- a/safe_locking_service/locking_events/tests/utils.py +++ b/safe_locking_service/locking_events/tests/utils.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta from django.utils import timezone @@ -6,6 +6,7 @@ from eth_typing import ChecksumAddress from web3 import Web3 from web3.contract import Contract +from web3.types import RPCEndpoint from .factories import LockEventFactory, UnlockEventFactory, WithdrawnEventFactory @@ -59,6 +60,21 @@ def locking_contract_unlock( return tx_receipt +def locking_contract_withdraw( + w3: Web3, signer_account: LocalAccount, locking_contract: Contract, max_unlocks: int +): + tx_withdrawn = locking_contract.functions.withdraw(max_unlocks).build_transaction( + { + "from": signer_account.address, + "nonce": w3.eth.get_transaction_count(signer_account.address), + } + ) + signed_tx = signer_account.sign_transaction(tx_withdrawn) + tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction) + tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + return tx_receipt + + def add_sorted_events( address: ChecksumAddress, lock_amount: int, @@ -87,3 +103,29 @@ def add_sorted_events( WithdrawnEventFactory( holder=address, amount=withdrawn_amount, timestamp=timezone.now() ) + + +def increment_chain_time(w3: Web3, increased_time: int) -> None: + """ + Increase the time to some future point manually to simulate any test situation where you need to wait for some time. + Uses `evm_increaseTime` supported by Ganache and Hardhat. + + :param w3: Ethereum client w3. + :param increased_time: Time in seconds to be advanced. + :return: + """ + current_timestamp = w3.eth.get_block("latest").get("timestamp") + date = datetime.fromtimestamp(current_timestamp) + new_date = datetime( + date.year, + date.month, + date.day, + date.hour, + date.minute, + date.second + increased_time, + ) + + w3.provider.make_request( + RPCEndpoint("evm_increaseTime"), [int(new_date.timestamp()) - current_timestamp] + ) + w3.provider.make_request(RPCEndpoint("evm_mine"), [])