Compare commits
	
		
			No commits in common. "b66aa8b1586a31ddad0c2454e4762661c63385a1" and "b5fc8e90018f4314d10df307755064cc9a70ee14" have entirely different histories.
		
	
	
		
			
				b66aa8b158
			
			...
			
				b5fc8e9001
			
		
	
		
					 22 changed files with 46 additions and 312 deletions
				
			
		
							
								
								
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -2,5 +2,3 @@ venv | |||
| __pycache__ | ||||
| config.json | ||||
| state.json | ||||
| .coverage | ||||
| test_state.json | ||||
|  |  | |||
|  | @ -61,7 +61,7 @@ All options are optional (but the companion would do nothing). | |||
| ## Usage | ||||
| 
 | ||||
| ``` | ||||
| python3 companion/main.py --help | ||||
| python3 main.py --help | ||||
| ``` | ||||
| 
 | ||||
| 
 | ||||
|  | @ -69,14 +69,12 @@ python3 companion/main.py --help | |||
| 
 | ||||
| Contributions are welcomed! Feel free to update the code and create a pull-request. | ||||
| 
 | ||||
| Be sure to lint the code and run tests before: | ||||
| Be sure to lint the code before: | ||||
| 
 | ||||
| ``` | ||||
| docker build -t pre-commit . | ||||
| docker run -it -v $(pwd):/mnt/ --rm pre-commit bash | ||||
| # cd /mnt/ | ||||
| # pip install -r requirements.txt | ||||
| # pre-commit run --all-files | ||||
| # pytest | ||||
| # exit | ||||
| ``` | ||||
|  |  | |||
|  | @ -79,7 +79,7 @@ def main(): | |||
|             last_balance, last_transaction = handler.watch_miner(address=config['miner'], | ||||
|                                                                  last_balance=pool_state.get('balance'), | ||||
|                                                                  last_transaction=pool_state.get('payment')) | ||||
|             if last_balance is not None: | ||||
|             if last_balance: | ||||
|                 logger.debug(f'saving {pool} miner balance to state file') | ||||
|                 state.write(pool_name=pool, miner_balance=last_balance) | ||||
|             if last_transaction: | ||||
|  | @ -30,20 +30,22 @@ class Handler: | |||
|         return miner.raw_balance | ||||
| 
 | ||||
|     def _watch_miner_payments(self, miner, last_transaction=None): | ||||
|         logger.debug('watching miner payments') | ||||
|         if miner.last_transaction and (not last_transaction or miner.last_transaction.txid != last_transaction): | ||||
|             # send notifications for last payment only | ||||
|             logger.info(f'new payment {miner.last_transaction.txid}') | ||||
|             if self.notifier: | ||||
|                 logger.debug('sending payment notification') | ||||
|                 arguments = {'pool': self.pool_name, 'address': miner.address, 'txid': miner.last_transaction.txid, | ||||
|                              'amount': miner.last_transaction.amount, 'amount_fiat': miner.last_transaction.amount_fiat, | ||||
|                              'time': miner.last_transaction.time, 'duration': miner.last_transaction.duration} | ||||
|                 try: | ||||
|                     self.notifier.notify_payment(**arguments) | ||||
|                     logger.info('payment notification sent') | ||||
|                 except Exception as err: | ||||
|                     logger.error('failed to send notification') | ||||
|                     logger.exception(err) | ||||
|         if miner.last_transaction and miner.last_transaction.txid != last_transaction: | ||||
|             logger.debug('watching miner payments') | ||||
|             # send notifications for recent payements only | ||||
|             for transaction in miner.transactions[MAX_NOTIFICATIONS_COUNT:]: | ||||
|                 if not last_transaction or transaction.txid > last_transaction: | ||||
|                     logger.info(f'new payment {transaction.txid}') | ||||
|                     if self.notifier: | ||||
|                         logger.debug('sending payment notification') | ||||
|                         arguments = {'pool': self.pool_name, 'address': miner.address, 'txid': transaction.txid, | ||||
|                                      'amount': transaction.amount, 'amount_fiat': transaction.amount_fiat, | ||||
|                                      'time': transaction.time, 'duration': transaction.duration} | ||||
|                         try: | ||||
|                             self.notifier.notify_payment(**arguments) | ||||
|                             logger.info('payment notification sent') | ||||
|                         except Exception as err: | ||||
|                             logger.error('failed to send notification') | ||||
|                             logger.exception(err) | ||||
|         if miner.last_transaction and miner.last_transaction.txid: | ||||
|             return miner.last_transaction.txid | ||||
|  | @ -40,13 +40,12 @@ class Miner: | |||
|         miner = flexpoolapi.miner(address) | ||||
|         self.raw_balance = miner.balance() | ||||
|         self.balance = convert_weis(self.raw_balance) | ||||
|         self.balance_fiat = None | ||||
|         if exchange_rate and currency: | ||||
|             self.balance_fiat = convert_fiat(amount=self.raw_balance, exchange_rate=exchange_rate, currency=currency) | ||||
|         payout_threshold = self.get_payout_threshold(miner) | ||||
|         self.balance_percentage = self.format_balance_percentage(payout_threshold=payout_threshold, | ||||
|                                                                  balance=self.raw_balance) | ||||
|         self.transactions = self.get_payements(miner, exchange_rate=exchange_rate, currency=currency) | ||||
|         self.transactions = self.get_payements(miner) | ||||
| 
 | ||||
|     @property | ||||
|     def url(self): | ||||
|  | @ -131,8 +130,7 @@ class FlexpoolHandler(Handler): | |||
|         blocks = self.get_blocks(exchange_rate=self.exchange_rate, currency=self.currency) | ||||
|         if blocks: | ||||
|             # don't spam block notification at initialization | ||||
|             notification_slice = MAX_NOTIFICATIONS_COUNT if len(blocks) > MAX_NOTIFICATIONS_COUNT else 0 | ||||
|             for block in blocks[notification_slice:]: | ||||
|             for block in blocks[MAX_NOTIFICATIONS_COUNT:]: | ||||
|                 if not last_block or last_block < block.number: | ||||
|                     logger.info(f'new block {block.number}') | ||||
|                     if self.notifier: | ||||
|  | @ -146,45 +144,35 @@ class FlexpoolHandler(Handler): | |||
|                         except Exception as err: | ||||
|                             logger.error('failed to send notification') | ||||
|                             logger.exception(err) | ||||
|                 last_remote_block = block | ||||
|             last_remote_block = block | ||||
|         if last_remote_block and last_remote_block.number: | ||||
|             return last_remote_block.number | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def get_blocks(exchange_rate=None, currency=None): | ||||
|         try: | ||||
|             remote_blocks = flexpoolapi.pool.last_blocks(count=MAX_BLOCKS_COUNT) | ||||
|             # convert to blocks | ||||
|             blocks = [] | ||||
|             if remote_blocks: | ||||
|                 for remote_block in remote_blocks: | ||||
|                     block = Block(number=remote_block.number, hash=remote_block.hash, time=remote_block.time, | ||||
|                                   round_time=remote_block.round_time, reward=remote_block.total_rewards, | ||||
|                                   luck=remote_block.luck, exchange_rate=exchange_rate, currency=currency) | ||||
|                     blocks.append(block) | ||||
|             # sort by block number | ||||
|             return sorted(blocks) | ||||
|         except flexpoolapi.exceptions.APIError as err: | ||||
|             logger.warning('failed to get blocks from Flexpool API') | ||||
|             logger.debug(err) | ||||
|         remote_blocks = flexpoolapi.pool.last_blocks(count=MAX_BLOCKS_COUNT) | ||||
|         # convert to blocks | ||||
|         blocks = [] | ||||
|         for remote_block in remote_blocks: | ||||
|             block = Block(number=remote_block.number, hash=remote_block.hash, time=remote_block.time, | ||||
|                           round_time=remote_block.round_time, reward=remote_block.total_rewards, luck=remote_block.luck, | ||||
|                           exchange_rate=exchange_rate, currency=currency) | ||||
|             blocks.append(block) | ||||
|         # sort by block number | ||||
|         return sorted(blocks) | ||||
| 
 | ||||
|     def watch_miner(self, address, last_balance=None, last_transaction=None): | ||||
|         logger.debug(f'watching miner {address}') | ||||
|         try: | ||||
|             miner = Miner(address=address, exchange_rate=self.exchange_rate, currency=self.currency) | ||||
|             logger.debug(miner) | ||||
| 
 | ||||
|             last_balance = self._watch_miner_balance(miner=miner, last_balance=last_balance) | ||||
|             last_transaction = self._watch_miner_payments(miner=miner, last_transaction=last_transaction) | ||||
| 
 | ||||
|             return last_balance, last_transaction | ||||
|         except flexpoolapi.exceptions.InvalidMinerAddress as err: | ||||
|             logger.error(f'miner address {address} is invalid') | ||||
|             logger.debug(err) | ||||
|         except flexpoolapi.exceptions.MinerDoesNotExist as err: | ||||
|         except Exception as err: | ||||
|             logger.error(f'miner {address} not found') | ||||
|             logger.debug(err) | ||||
|         except flexpoolapi.exceptions.APIError as err: | ||||
|             logger.warning('failed to get miner from Flexpool API') | ||||
|             logger.debug(err) | ||||
|         return None, None | ||||
|             logger.exception(err) | ||||
|             return | ||||
| 
 | ||||
|         logger.debug(miner) | ||||
| 
 | ||||
|         last_balance = self._watch_miner_balance(miner=miner, last_balance=last_balance) | ||||
|         last_transaction = self._watch_miner_payments(miner=miner, last_transaction=last_transaction) | ||||
| 
 | ||||
|         return last_balance, last_transaction | ||||
|  | @ -1,2 +0,0 @@ | |||
| [pytest] | ||||
| addopts = --cov=companion tests/ | ||||
|  | @ -1,4 +1,3 @@ | |||
| -r requirements/base.txt | ||||
| -r requirements/ethermine.txt | ||||
| -r requirements/flexpool.txt | ||||
| -r requirements/tests.txt | ||||
|  |  | |||
|  | @ -1,3 +0,0 @@ | |||
| pytest==6.2.2 | ||||
| pytest-cov==2.11.1 | ||||
| pytest-mock==3.5.1 | ||||
|  | @ -20,9 +20,9 @@ class State: | |||
|         content = self.read() | ||||
|         if pool_name not in content: | ||||
|             content[pool_name] = {} | ||||
|         if block_number is not None: | ||||
|         if block_number: | ||||
|             content[pool_name]['block'] = block_number | ||||
|         if miner_balance is not None: | ||||
|         if miner_balance: | ||||
|             content[pool_name]['balance'] = miner_balance | ||||
|         if miner_payment: | ||||
|             content[pool_name]['payment'] = miner_payment | ||||
|  | @ -18,7 +18,7 @@ class TelegramNotifier: | |||
|     @staticmethod | ||||
|     def _markdown_escape(text): | ||||
|         text = str(text) | ||||
|         for special_char in ['\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!', '=']: | ||||
|         for special_char in ['\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!']: | ||||
|             text = text.replace(special_char, fr'\{special_char}') | ||||
|         return text | ||||
| 
 | ||||
|  | @ -1,4 +0,0 @@ | |||
| import os | ||||
| import sys | ||||
| 
 | ||||
| sys.path.append(os.path.join(os.path.dirname(__file__), os.pardir, 'companion')) | ||||
|  | @ -1,142 +0,0 @@ | |||
| from datetime import datetime, timedelta | ||||
| 
 | ||||
| import pytest | ||||
| from companion.pools.flexpool import FlexpoolHandler, Transaction | ||||
| from flexpoolapi.shared import Block as BlockApi | ||||
| 
 | ||||
| 
 | ||||
| class TestFlexpoolHandler: | ||||
|     def test_init(self): | ||||
|         handler = FlexpoolHandler() | ||||
|         assert handler.pool_name == 'flexpool' | ||||
| 
 | ||||
|     @pytest.mark.parametrize( | ||||
|         'old_balance,new_balance,should_notify', | ||||
|         [ | ||||
|             pytest.param(1, 2, True, id='new_balance_with_notification'), | ||||
|             pytest.param(1, 0, True, id='new_balance_after_payment_with_notification'), | ||||
|             pytest.param(None, 1, True, id='very_new_balance_with_notification'), | ||||
|             pytest.param(1, 1, False, id='same_balance_without_notification'), | ||||
|         ] | ||||
|     ) | ||||
|     def test_balance(self, mocker, old_balance, new_balance, should_notify): | ||||
|         notifier = mocker.Mock() | ||||
|         notifier.notify_balance = mocker.Mock() | ||||
|         handler = FlexpoolHandler(notifier=notifier) | ||||
|         miner = mocker.patch('flexpoolapi.miner') | ||||
|         miner().balance.return_value = new_balance | ||||
|         mocker.patch('companion.pools.flexpool.FlexpoolHandler._watch_miner_payments') | ||||
|         mocker.patch('companion.pools.flexpool.Miner.get_payements') | ||||
|         last_balance, last_transaction = handler.watch_miner(address='addr', last_balance=old_balance) | ||||
|         assert last_balance == new_balance | ||||
|         if should_notify: | ||||
|             notifier.notify_balance.assert_called_once() | ||||
|         else: | ||||
|             notifier.notify_balance.assert_not_called() | ||||
| 
 | ||||
|     def test_balance_with_api_failure(self, mocker): | ||||
|         """An API failure should not send a balance notification""" | ||||
|         notifier = mocker.Mock() | ||||
|         notifier.notify_balance = mocker.Mock() | ||||
|         handler = FlexpoolHandler(notifier=notifier) | ||||
|         request_get = mocker.patch('requests.get') | ||||
|         request_get.return_value.status_code = 503 | ||||
|         mocker.patch('companion.pools.flexpool.FlexpoolHandler._watch_miner_payments') | ||||
|         mocker.patch('companion.pools.flexpool.Miner.get_payements') | ||||
|         last_balance, last_transaction = handler.watch_miner(address='0000000000000000000000000000000000000001', | ||||
|                                                              last_balance=1) | ||||
|         assert last_balance is None | ||||
|         notifier.notify_balance.assert_not_called() | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def _create_transactions(names): | ||||
|         if names: | ||||
|             return [Transaction(txid=n, amount=1, time=datetime.now(), duration=timedelta(minutes=1)) for n in names] | ||||
| 
 | ||||
|     @pytest.mark.parametrize( | ||||
|         'old_transaction,new_transactions,should_notify', | ||||
|         [ | ||||
|             pytest.param('trx1', ['trx1', 'trx2'], True, id='new_payment_with_notification'), | ||||
|             pytest.param(None, ['trx1'], True, id='very_new_payment_with_notification'), | ||||
|             pytest.param('trx1', ['trx1'], False, id='same_payment_without_notification'), | ||||
|             pytest.param(None, None, False, id='zero_payment_without_notification'), | ||||
|         ] | ||||
|     ) | ||||
|     def test_payments(self, mocker, old_transaction, new_transactions, should_notify): | ||||
|         notifier = mocker.Mock() | ||||
|         notifier.notify_payment = mocker.Mock() | ||||
|         handler = FlexpoolHandler(notifier=notifier) | ||||
|         mocker.patch('flexpoolapi.miner') | ||||
|         mocker.patch('companion.pools.flexpool.FlexpoolHandler._watch_miner_balance') | ||||
|         get_payements = mocker.patch('companion.pools.flexpool.Miner.get_payements') | ||||
|         get_payements.return_value = self._create_transactions(new_transactions) | ||||
|         last_balance, last_transaction = handler.watch_miner(address='addr', last_transaction=old_transaction) | ||||
|         if new_transactions: | ||||
|             assert last_transaction == new_transactions[-1] | ||||
|         else: | ||||
|             assert last_transaction is None | ||||
|         if should_notify: | ||||
|             notifier.notify_payment.assert_called_once() | ||||
|         else: | ||||
|             notifier.notify_payment.assert_not_called() | ||||
| 
 | ||||
|     def test_payment_with_api_failure(self, mocker): | ||||
|         """An API failure should not send a payment notification""" | ||||
|         notifier = mocker.Mock() | ||||
|         notifier.notify_payment = mocker.Mock() | ||||
|         handler = FlexpoolHandler(notifier=notifier) | ||||
|         request_get = mocker.patch('requests.get') | ||||
|         request_get.return_value.status_code = 503 | ||||
|         mocker.patch('companion.pools.flexpool.FlexpoolHandler._watch_miner_balance') | ||||
|         last_balance, last_transaction = handler.watch_miner(address='0000000000000000000000000000000000000001', | ||||
|                                                              last_transaction=1) | ||||
|         assert last_transaction is None | ||||
|         notifier.notify_payment.assert_not_called() | ||||
| 
 | ||||
|     @staticmethod | ||||
|     def _create_blocks(numbers): | ||||
|         if numbers: | ||||
|             blocks = [] | ||||
|             for number in numbers: | ||||
|                 blocks.append(BlockApi(number=number, blockhash='h', block_type='bt', miner='m', difficulty=1, | ||||
|                                        timestamp=1, is_confirmed=True, round_time=1, luck=1.0, server_name='s', | ||||
|                                        block_reward=1, block_fees=1, uncle_inclusion_rewards=1, total_rewards=1)) | ||||
|             return blocks | ||||
| 
 | ||||
|     @pytest.mark.parametrize( | ||||
|         'last_block,remote_blocks,should_notify', | ||||
|         [ | ||||
|             pytest.param(1, [1, 2], True, id='new_block_with_notification'), | ||||
|             pytest.param(None, [1], True, id='very_new_block_with_notification'), | ||||
|             pytest.param(1, [1], False, id='same_block_without_notification'), | ||||
|             pytest.param(9, range(1, 11), True, id='new_block_with_count_over_max_notification'), | ||||
|             pytest.param(10, range(1, 11), False, id='same_block_with_count_over_max_notification'), | ||||
|             pytest.param(None, None, False, id='zero_block_without_notification'), | ||||
|         ] | ||||
|     ) | ||||
|     def test_block(self, mocker, last_block, remote_blocks, should_notify): | ||||
|         notifier = mocker.Mock() | ||||
|         notifier.notify_block = mocker.Mock() | ||||
|         handler = FlexpoolHandler(notifier=notifier) | ||||
|         last_blocks = mocker.patch('flexpoolapi.pool.last_blocks') | ||||
|         last_blocks.return_value = self._create_blocks(remote_blocks) | ||||
|         block = handler.watch_blocks(last_block=last_block) | ||||
|         if remote_blocks: | ||||
|             assert block == remote_blocks[-1] | ||||
|         else: | ||||
|             assert block is None | ||||
|         if should_notify: | ||||
|             notifier.notify_block.assert_called_once() | ||||
|         else: | ||||
|             notifier.notify_block.assert_not_called() | ||||
| 
 | ||||
|     def test_block_with_api_failure(self, mocker): | ||||
|         """An API failure should not send a block notification""" | ||||
|         notifier = mocker.Mock() | ||||
|         notifier.notify_block = mocker.Mock() | ||||
|         handler = FlexpoolHandler(notifier=notifier) | ||||
|         request_get = mocker.patch('requests.get') | ||||
|         request_get.return_value.status_code = 503 | ||||
|         block = handler.watch_blocks(last_block=1) | ||||
|         assert block is None | ||||
|         notifier.notify_block.assert_not_called() | ||||
|  | @ -1,100 +0,0 @@ | |||
| import json | ||||
| import os | ||||
| 
 | ||||
| import pytest | ||||
| from companion.state import State | ||||
| 
 | ||||
| 
 | ||||
| class TestState: | ||||
|     FILENAME = 'test_state.json' | ||||
|     POOL_NAME = 'testpool' | ||||
|     CONTENT = { | ||||
|         'testpool': { | ||||
|             'block': 1234, | ||||
|             'balance': 1234, | ||||
|             'payment': '0x0000000' | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @pytest.fixture(scope='function') | ||||
|     def state(self): | ||||
|         return State(self.FILENAME) | ||||
| 
 | ||||
|     @pytest.fixture(scope='function') | ||||
|     def create_state(self): | ||||
|         with open(self.FILENAME, 'w') as fd: | ||||
|             json.dump(self.CONTENT, fd, indent=2) | ||||
|         yield | ||||
|         if os.path.isfile(self.FILENAME): | ||||
|             os.unlink(self.FILENAME) | ||||
| 
 | ||||
|     @pytest.fixture(scope='function') | ||||
|     def remove_state(self): | ||||
|         yield | ||||
|         if os.path.isfile(self.FILENAME): | ||||
|             os.unlink(self.FILENAME) | ||||
| 
 | ||||
|     def test_init(self, state, remove_state): | ||||
|         assert os.path.isfile(self.FILENAME) | ||||
|         with open(self.FILENAME, 'r') as fd: | ||||
|             assert json.load(fd) == {} | ||||
| 
 | ||||
|     def test_read(self, state, create_state): | ||||
|         content = state.read() | ||||
|         for pool in self.CONTENT: | ||||
|             assert pool in content | ||||
|             for key in self.CONTENT[pool]: | ||||
|                 assert key in content[pool] and content[pool][key] == self.CONTENT[pool][key] | ||||
| 
 | ||||
|     def test_write(self, state): | ||||
|         state.write(pool_name=self.POOL_NAME) | ||||
|         content = state.read() | ||||
|         assert content[self.POOL_NAME] == {} | ||||
| 
 | ||||
|     def test_write_block(self, create_state, state): | ||||
|         state.write(pool_name=self.POOL_NAME, block_number=5678) | ||||
|         content = state.read() | ||||
|         assert content[self.POOL_NAME]['block'] == 5678 | ||||
| 
 | ||||
|     def test_write_empty_block(self, create_state, state): | ||||
|         state.write(pool_name=self.POOL_NAME, block_number=None) | ||||
|         content = state.read() | ||||
|         assert content[self.POOL_NAME]['block'] == self.CONTENT[self.POOL_NAME]['block']  # not changed | ||||
| 
 | ||||
|     def test_write_zero_block(self, create_state, state): | ||||
|         state.write(pool_name=self.POOL_NAME, block_number=0) | ||||
|         content = state.read() | ||||
|         assert content[self.POOL_NAME]['block'] == 0 | ||||
| 
 | ||||
|     def test_write_balance(self, create_state, state): | ||||
|         state.write(pool_name=self.POOL_NAME, miner_balance=5678) | ||||
|         content = state.read() | ||||
|         assert content[self.POOL_NAME]['balance'] == 5678 | ||||
| 
 | ||||
|     def test_write_empty_balance(self, create_state, state): | ||||
|         state.write(pool_name=self.POOL_NAME, miner_balance=None) | ||||
|         content = state.read() | ||||
|         assert content[self.POOL_NAME]['balance'] == self.CONTENT[self.POOL_NAME]['balance']  # not changed | ||||
| 
 | ||||
|     def test_write_zero_balance(self, create_state, state): | ||||
|         state.write(pool_name=self.POOL_NAME, miner_balance=0) | ||||
|         content = state.read() | ||||
|         assert content[self.POOL_NAME]['balance'] == 0 | ||||
| 
 | ||||
|     def test_write_payment(self, create_state, state): | ||||
|         state.write(pool_name=self.POOL_NAME, miner_payment='0x1111111') | ||||
|         content = state.read() | ||||
|         assert content[self.POOL_NAME]['payment'] == '0x1111111' | ||||
| 
 | ||||
|     def test_write_empty_payment(self, create_state, state): | ||||
|         state.write(pool_name=self.POOL_NAME, miner_payment=None) | ||||
|         content = state.read() | ||||
|         assert content[self.POOL_NAME]['payment'] == self.CONTENT[self.POOL_NAME]['payment']  # not changed | ||||
| 
 | ||||
|     def test_get(self, create_state): | ||||
|         state = State(filename=self.FILENAME) | ||||
|         assert state.get(self.POOL_NAME) == self.CONTENT[self.POOL_NAME] | ||||
| 
 | ||||
|     def test_get_missing_key(self, create_state): | ||||
|         state = State(filename=self.FILENAME) | ||||
|         assert state.get('UNKNOWN_POOL') == {} | ||||
		Reference in a new issue