diff --git a/README.md b/README.md index 7ab7f55..d5cb7f2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,19 @@ -# flexpool-activity +# mining-companion -[Flexpool.io](https://flexpool.io) is a next-generation [Ethereum](https://ethereum.org/en/) mining pool known for their -[#STOPEIP1559](https://stopeip1559.org/) move. `flexpool-activity` is able to listen and notify when a new **block** is -mined by the pool, display the up-to-date **miner balance** and convert it to **fiat**. +Cryptocurrency mining interest has raised recently due to high [Ethereum](https://ethereum.org/en/) profitability. You +can opt for the solo-mining path or use a **mining pool** to increase your chances to receive block rewards. + +`mining-companion` is able to listen and notify for the following events: +* new **block** is mined by the mining pool +* unpaid **balance** is updated +* new **payment** has been sent by the mining pool + +Notifications are sent via [Telegram Messenger](https://telegram.org/). + +## Supported pools + +* [Ethermine](https://ethermine.org/) +* [Flexpool.io](https://flexpool.io) ## Installation @@ -10,6 +21,20 @@ mined by the pool, display the up-to-date **miner balance** and convert it to ** sudo apt install python3-virtualenv virtualenv venv source venv/bin/activate +``` + +Pool libraries are loaded at execution time. For example, if you use only "flexpool" mining pool, you don't need to +install "ethermine" libraries. Requirements files have been splitted to install only libraries you need. + +``` +pip install -r requirements/base.txt +pip install -r requirements/ethermine.txt +pip install -r requirements/flexpool.txt +``` + +To install all libraries at once: + +``` pip install -r requirements.txt ``` @@ -21,15 +46,17 @@ bot. You'll need the `chat_id` and `auth_key` for the next section. ## Configuration Configuration file use the JSON format with the following keys: -* `miner`: wallet address of the miner (optional) -* `currency`: symbol of the currency to convert (optional) -* `telegram`: send notifications with Telegram (optional) +* `pools`: list of mining pools +* `miner`: wallet address of the miner +* `currency`: symbol of the currency to convert +* `telegram`: send notifications with Telegram * `auth_key`: Telegram authentication key for the bot API * `chat_id`: Telegram chat room id (where to send the message) * `state_file`: persist data between runs into this file (default: `state.json`) See [configuration example](config.example.json). +All options are optional (but the companion would do nothing). ## Usage diff --git a/config.example.json b/config.example.json index dcbdf69..7d1b5bc 100644 --- a/config.example.json +++ b/config.example.json @@ -1,5 +1,9 @@ { - "miner": "string", + "pools": [ + "ethermine", + "flexpool" + ], + "miner": "your.eth.address", "currency": "USD", "telegram": { "chat_id": 123, diff --git a/config.schema.json b/config.schema.json index cc26176..9079b85 100644 --- a/config.schema.json +++ b/config.schema.json @@ -22,6 +22,17 @@ "miner": { "type": "string" }, + "pools": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "flexpool", + "ethermine" + ] + }, + "uniqueItems": true + }, "state_file": { "type": "string" } diff --git a/flexpool.py b/flexpool.py deleted file mode 100644 index 6465866..0000000 --- a/flexpool.py +++ /dev/null @@ -1,113 +0,0 @@ -import flexpoolapi -from flexpoolapi.utils import format_weis -from humanfriendly import format_timespan - - -class BlockNotFoundException(Exception): - pass - - -def convert_weis(weis, prec=5): - return round(weis / 10**18, prec) - - -class LastBlock: - def __init__(self, exchange_rate=None, currency=None): - self._exchange_rate = exchange_rate - self._currency = currency - block = self.get_last_block() - self.hash = block.hash - self.number = block.number - self.time = block.time - self.raw_reward = block.total_rewards - self.reward = format_weis(block.total_rewards) - self.reward_fiat = self.convert_reward() - self.round_time = format_timespan(block.round_time) - self.luck = f'{int(block.luck*100)}%' - - @staticmethod - def get_last_block(): - block = flexpoolapi.pool.last_blocks(count=1)[0] - if not block: - raise BlockNotFoundException('No block found') - return block - - def convert_reward(self): - if self._exchange_rate and self._currency: - converted = round(convert_weis(self.raw_reward)*self._exchange_rate, 2) - converted = f'{converted} {self._currency}' - return converted - - def __repr__(self): - attributes = {'time': self.time, 'reward': self.reward, 'round_time': self.round_time, 'luck': self.luck} - if self.reward_fiat: - attributes['reward_fiat'] = self.reward_fiat - formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()]) - return f'' - - -class Miner: - def __init__(self, address, exchange_rate=None, currency=None): - self.address = address - self._exchange_rate = exchange_rate - self._currency = currency - miner = flexpoolapi.miner(address) - self.raw_balance = miner.balance() - self.details = miner.details() - self.balance = self.format_balance() - self.balance_fiat = self.convert_balance() - self.balance_percentage = self.format_balance_percentage() - - last_transactions = miner.payments_paged(page=0) - if last_transactions: - trx = last_transactions[0] - self.last_transaction = Transaction(amount=trx.amount, time=trx.time, duration=trx.duration, txid=trx.txid, - exchange_rate=exchange_rate, currency=currency) - else: - self.last_transaction = None - - def format_balance(self): - return format_weis(self.raw_balance) - - def format_balance_percentage(self): - return f'{round(self.raw_balance*100/self.details.min_payout_threshold, 2)}%' - - def convert_balance(self): - if self._exchange_rate and self._currency: - converted = round(convert_weis(self.raw_balance)*self._exchange_rate, 2) - converted = f'{converted} {self._currency}' - return converted - - def __repr__(self): - attributes = {'balance': self.balance, 'raw_balance': self.raw_balance, - 'balance_percentage': self.balance_percentage} - if self.balance_fiat: - attributes['balance_fiat'] = self.balance_fiat - formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()]) - return f'' - - -class Transaction: - def __init__(self, amount, time, duration, txid, exchange_rate=None, currency=None): - self._exchange_rate = exchange_rate - self._currency = currency - self.raw_amount = amount - self.amount = format_weis(amount) - self.amount_fiat = self.convert_amount() - self.duration = format_timespan(duration) - self.txid = txid - self.time = time - - def convert_amount(self): - if self._exchange_rate and self._currency: - converted = round(convert_weis(self.raw_amount)*self._exchange_rate, 2) - converted = f'{converted} {self._currency}' - return converted - - def __repr__(self): - attributes = {'time': self.time, 'amount': self.amount, 'raw_amount': self.raw_amount, - 'duration': self.duration} - if self.amount_fiat: - attributes['amount_fiat'] = self.amount_fiat - formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()]) - return f'' diff --git a/main.py b/main.py index 2a0a784..8ab3ec3 100644 --- a/main.py +++ b/main.py @@ -2,12 +2,10 @@ import argparse import logging -import telegram from coingecko import get_rate from config import read_config, validate_config -from flexpool import BlockNotFoundException, LastBlock, Miner from requests.exceptions import HTTPError -from state import create_state, read_state, write_state +from state import State logger = logging.getLogger(__name__) @@ -34,81 +32,6 @@ def setup_logging(args): logging.basicConfig(format=log_format, level=args.loglevel, filename=args.logfile) -def watch_block(config, disable_notifications, last_block=None, exchange_rate=None, currency=None): - logger.debug('fetching last mined block') - try: - block = LastBlock(exchange_rate=exchange_rate, currency=currency) - except BlockNotFoundException: - logger.warning('last block found') - return - - if block.number != last_block: - logger.info(f'block {block.number} mined') - logger.debug(block) - - if not disable_notifications and config.get('telegram'): - logger.debug('sending block notification to telegram') - variables = {'hash': block.hash, 'number': block.number, 'time': block.time, 'reward': block.reward, - 'reward_fiat': block.reward_fiat, 'round_time': block.round_time, 'luck': block.luck} - payload = telegram.create_block_payload(chat_id=config['telegram']['chat_id'], message_variables=variables) - try: - telegram.send_message(auth_key=config['telegram']['auth_key'], payload=payload) - logger.info('block notification sent to telegram') - except HTTPError as err: - logger.error('failed to send notification to telegram') - logger.exception(err) - - return block - - -def watch_miner(address, config, disable_notifications, last_balance=None, last_transaction=None, exchange_rate=None, - currency=None): - logger.debug(f'watching miner {address}') - try: - miner = Miner(address=address, exchange_rate=exchange_rate, currency=currency) - except Exception as err: - logger.error('failed to find miner') - logger.exception(err) - return - - logger.debug(miner) - - logger.debug('watching miner balance') - if miner.raw_balance != last_balance: - logger.info(f'miner {address} balance has changed') - if not disable_notifications and config.get('telegram'): - logger.debug('sending balance notification to telegram') - variables = {'address': address, 'balance': miner.balance, 'balance_fiat': miner.balance_fiat, - 'balance_percentage': miner.balance_percentage} - payload = telegram.create_balance_payload(chat_id=config['telegram']['chat_id'], - message_variables=variables) - try: - telegram.send_message(auth_key=config['telegram']['auth_key'], payload=payload) - logger.info('balance notification sent to telegram') - except HTTPError as err: - logger.error('failed to send notification to telegram') - logger.debug(str(err)) - - logger.debug('watching miner payments') - if miner.last_transaction and miner.last_transaction.txid != last_transaction: - logger.info(f'new payment for miner {address}') - if not disable_notifications and config.get('telegram'): - logger.debug('sending payment notification to telegram') - variables = {'address': 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} - payload = telegram.create_payment_payload(chat_id=config['telegram']['chat_id'], - message_variables=variables) - try: - telegram.send_message(auth_key=config['telegram']['auth_key'], payload=payload) - logger.info('payment notification sent to telegram') - except HTTPError as err: - logger.error('failed to send notification to telegram') - logger.debug(str(err)) - - return miner - - def main(): args = parse_arguments() setup_logging(args) @@ -116,13 +39,16 @@ def main(): config = read_config(args.config) validate_config(config) - state_file = config.get('state_file', DEFAULT_STATE_FILE) - create_state(state_file) - state = read_state(state_file) + state = State(filename=config.get('state_file', DEFAULT_STATE_FILE)) exchange_rate = None currency = config.get('currency') + notifier = None + if config.get('telegram') and not args.disable_notifications: + from telegram import TelegramNotifier + notifier = TelegramNotifier(**config['telegram']) + if currency: logger.debug('fetching current rate') try: @@ -131,22 +57,34 @@ def main(): logger.warning(f'failed to get ETH/{currency} rate') logger.debug(str(err)) - block = watch_block(last_block=state.get('block'), config=config, disable_notifications=args.disable_notifications, - exchange_rate=exchange_rate, currency=currency) - if block: - logger.debug('saving block number to state file') - write_state(state_file, block_number=block.number) + for pool in config.get('pools', []): + pool_state = state.get(pool) - if config.get('miner'): - miner = watch_miner(last_balance=state.get('balance'), last_transaction=state.get('payment'), - address=config['miner'], config=config, disable_notifications=args.disable_notifications, - exchange_rate=exchange_rate, currency=currency) - if miner: - logger.debug('saving miner balance to state file') - write_state(state_file, miner_balance=miner.raw_balance) - if miner.last_transaction and miner.last_transaction.txid: - logger.debug('saving miner payment to state file') - write_state(state_file, miner_payment=miner.last_transaction.txid) + if pool == 'flexpool': + from pools.flexpool import FlexpoolHandler + handler = FlexpoolHandler(exchange_rate=exchange_rate, currency=currency, notifier=notifier) + elif pool == 'ethermine': + from pools.ethermine import EthermineHandler + handler = EthermineHandler(exchange_rate=exchange_rate, currency=currency, notifier=notifier) + else: + logger.warning(f'pool {pool} not supported') + continue + + last_block = handler.watch_blocks(last_block=pool_state.get('block')) + if last_block: + logger.debug(f'saving {pool} block to state file') + state.write(pool_name=pool, block_number=last_block) + + if config.get('miner'): + 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: + logger.debug(f'saving {pool} miner balance to state file') + state.write(pool_name=pool, miner_balance=last_balance) + if last_transaction: + logger.debug(f'saving {pool} miner payment to state file') + state.write(pool_name=pool, miner_payment=last_transaction) if __name__ == '__main__': diff --git a/pools/__init__.py b/pools/__init__.py new file mode 100644 index 0000000..6523eda --- /dev/null +++ b/pools/__init__.py @@ -0,0 +1,51 @@ +import logging + +logger = logging.getLogger(__name__) + +MAX_NOTIFICATIONS_COUNT = 5 + + +class Handler: + def __init__(self, pool_name, exchange_rate=None, currency=None, notifier=None): + self.pool_name = pool_name + self.exchange_rate = exchange_rate + self.currency = currency + self.notifier = notifier + + def _watch_miner_balance(self, miner, last_balance=None): + logger.debug('watching miner balance') + if miner.raw_balance != last_balance: + logger.info('miner balance has changed') + if self.notifier: + logger.debug('sending balance notification') + arguments = {'pool': self.pool_name, 'address': miner.address, 'url': miner.url, + 'balance': miner.balance, 'balance_fiat': miner.balance_fiat, + 'balance_percentage': miner.balance_percentage} + try: + self.notifier.notify_balance(**arguments) + logger.info('balance notification sent') + except Exception as err: + logger.error('failed to send notification') + logger.exception(err) + return miner.raw_balance + + def _watch_miner_payments(self, miner, last_transaction=None): + 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 diff --git a/pools/ethermine.py b/pools/ethermine.py new file mode 100644 index 0000000..6f1e51c --- /dev/null +++ b/pools/ethermine.py @@ -0,0 +1,119 @@ +import logging +from datetime import datetime + +from ethermine import Ethermine +from humanfriendly import format_timespan +from pools import Handler +from utils import convert_fiat, format_weis + +logger = logging.getLogger(__name__) + +eth = Ethermine() + + +class Miner: + def __init__(self, address, exchange_rate=None, currency=None): + self.address = address + self.raw_balance = self.get_unpaid_balance(address) + self.balance = format_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(address) + self.balance_percentage = self.format_balance_percentage(payout_threshold=payout_threshold, + balance=self.raw_balance) + self.transactions = self.get_payouts(address, exchange_rate, currency) + + @staticmethod + def get_unpaid_balance(address): + dashboard = eth.miner_dashboard(address) + return dashboard['currentStatistics']['unpaid'] + + @staticmethod + def get_payouts(address, exchange_rate=None, currency=None): + payouts = eth.miner_payouts(address) + # convert to transactions + transactions = [] + for payout in payouts: + transaction = Transaction(txid=payout['txHash'], timestamp=payout['paidOn'], amount=payout['amount'], + duration=payout['end']-payout['start'], exchange_rate=exchange_rate, + currency=currency) + transactions.append(transaction) + # sort by older timestamp first + return sorted(transactions) + + @staticmethod + def get_payout_threshold(address): + return eth.miner_settings(address)['minPayout'] + + @staticmethod + def format_balance_percentage(payout_threshold, balance): + return f'{round(balance*100/payout_threshold, 2)}%' + + @property + def url(self): + return f'https://ethermine.org/miners/{self.address}/dashboard' + + @property + def last_transaction(self): + if self.transactions: + return self.transactions[-1] + + def __repr__(self): + attributes = {'balance': self.balance, 'raw_balance': self.raw_balance, + 'balance_percentage': self.balance_percentage, 'url': self.url} + if self.balance_fiat: + attributes['balance_fiat'] = self.balance_fiat + formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()]) + return f'' + + +class Transaction: + def __init__(self, txid, amount, timestamp, duration, exchange_rate=None, currency=None): + self.txid = txid + self.time = datetime.fromtimestamp(timestamp) + self.raw_amount = amount + self.amount = format_weis(amount) + self.amount_fiat = None + self.duration = format_timespan(duration) + if exchange_rate and currency: + self.amount_fiat = convert_fiat(amount=self.raw_amount, exchange_rate=exchange_rate, currency=currency) + + def __lt__(self, trx): + """Order by datetime asc""" + return self.time < trx.time + + def __eq__(self, trx): + return self.txid == trx.txid + + def __repr__(self): + attributes = {'time': self.time, 'amount': self.amount, 'raw_amount': self.raw_amount, + 'duration': self.duration} + if self.amount_fiat: + attributes['amount_fiat'] = self.amount_fiat + formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()]) + return f'' + + +class EthermineHandler(Handler): + def __init__(self, exchange_rate=None, currency=None, notifier=None, pool_name='ethermine'): + super().__init__(pool_name=pool_name, exchange_rate=exchange_rate, currency=currency, notifier=notifier) + + def watch_blocks(self, last_block=None): + logger.debug('not implemented yet') + + 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) + except Exception as err: + logger.error(f'miner {address} not found') + 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 diff --git a/pools/flexpool.py b/pools/flexpool.py new file mode 100644 index 0000000..9f2f50c --- /dev/null +++ b/pools/flexpool.py @@ -0,0 +1,178 @@ +import logging + +import flexpoolapi +from humanfriendly import format_timespan +from pools import MAX_NOTIFICATIONS_COUNT, Handler +from utils import convert_fiat, convert_weis, format_weis + +logger = logging.getLogger(__name__) + +MAX_BLOCKS_COUNT = 10 +MAX_PAYMENTS_COUNT = 10 + + +class Block: + def __init__(self, number, hash, time, round_time, reward, luck, exchange_rate=None, currency=None): + self.number = int(number) + self.hash = hash + self.time = time + self.round_time = format_timespan(round_time) + self.reward = format_weis(reward) + self.reward_fiat = None + if exchange_rate and currency: + self.reward_fiat = convert_fiat(amount=reward, exchange_rate=exchange_rate, currency=currency) + self.luck = f'{int(luck*100)}%' + + def __lt__(self, block): + return self.number < block.number + + def __repr__(self): + attributes = {'time': self.time, 'reward': self.reward, 'round_time': self.round_time, 'luck': self.luck} + if self.reward_fiat: + attributes['reward_fiat'] = self.reward_fiat + formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()]) + return f'' + + +class Miner: + def __init__(self, address, exchange_rate=None, currency=None): + self.address = address + miner = flexpoolapi.miner(address) + self.raw_balance = miner.balance() + self.balance = convert_weis(self.raw_balance) + 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) + + @property + def url(self): + return f'https://flexpool.io/{self.address}' + + @staticmethod + def get_payout_threshold(miner): + return miner.details().min_payout_threshold + + @staticmethod + def format_balance_percentage(payout_threshold, balance): + return f'{round(balance*100/payout_threshold, 2)}%' + + @staticmethod + def get_payements(miner, exchange_rate=None, currency=None): + # crawl payments + transactions = [] + payments_count = 0 + current_page = 0 + while payments_count < MAX_PAYMENTS_COUNT: + logger.debug(f'fetching payments page {current_page}') + page = miner.payments_paged(page=current_page) + if not page.contents: + break + for payment in page.contents: + # convert to transaction + transaction = Transaction(txid=payment.txid, time=payment.time, amount=payment.amount, + duration=payment.duration, exchange_rate=exchange_rate, + currency=currency) + transactions.append(transaction) + payments_count += 1 + current_page += 1 + if current_page > page.total_pages: + break + # sort transactions from oldest to newest + return sorted(transactions) + + @property + def last_transaction(self): + if self.transactions: + return self.transactions[-1] + + def __repr__(self): + attributes = {'balance': self.balance, 'raw_balance': self.raw_balance, + 'balance_percentage': self.balance_percentage, 'url': self.url} + if self.balance_fiat: + attributes['balance_fiat'] = self.balance_fiat + formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()]) + return f'' + + +class Transaction: + def __init__(self, txid, amount, time, duration, exchange_rate=None, currency=None): + self.txid = txid + self.time = time + self.raw_amount = amount + self.amount = format_weis(amount) + self.amount_fiat = None + self.duration = format_timespan(duration) + if exchange_rate and currency: + self.amount_fiat = convert_fiat(amount=self.raw_amount, exchange_rate=exchange_rate, currency=currency) + + def __lt__(self, trx): + return self.time < trx.time + + def __repr__(self): + attributes = {'time': self.time, 'amount': self.amount, 'raw_amount': self.raw_amount, + 'duration': self.duration} + if self.amount_fiat: + attributes['amount_fiat'] = self.amount_fiat + formatted_attributes = ' '.join([f'{k}="{v}"' for k, v in attributes.items()]) + return f'' + + +class FlexpoolHandler(Handler): + def __init__(self, exchange_rate=None, currency=None, notifier=None, pool_name='flexpool'): + super().__init__(pool_name=pool_name, exchange_rate=exchange_rate, currency=currency, notifier=notifier) + + def watch_blocks(self, last_block=None): + logger.debug('watching last blocks') + last_remote_block = None + blocks = self.get_blocks(exchange_rate=self.exchange_rate, currency=self.currency) + if blocks: + # don't spam block notification at initialization + 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: + logger.debug('sending block notification') + arguments = {'pool': self.pool_name, 'number': block.number, 'hash': block.hash, + 'reward': block.reward, 'time': block.time, 'round_time': block.round_time, + 'luck': block.luck, 'reward_fiat': block.reward_fiat} + try: + self.notifier.notify_block(**arguments) + logger.info('block notification sent') + except Exception as err: + logger.error('failed to send notification') + logger.exception(err) + 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): + 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) + except Exception as err: + logger.error(f'miner {address} not found') + 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 diff --git a/requirements.txt b/requirements.txt index 84c4ccd..1ad3230 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,31 +1,3 @@ -appdirs==1.4.3 -attrs==20.3.0 -CacheControl==0.12.6 -certifi==2019.11.28 -chardet==3.0.4 -colorama==0.4.3 -contextlib2==0.6.0 -distlib==0.3.0 -distro==1.4.0 -flexpoolapi==1.2.7.post2 -html5lib==1.0.1 -humanfriendly==9.1 -idna==2.8 -ipaddr==2.2.0 -Jinja2==2.11.2 -jsonschema==3.2.0 -lockfile==0.12.2 -MarkupSafe==1.1.1 -msgpack==0.6.2 -packaging==20.3 -pep517==0.8.2 -progress==1.5 -pyparsing==2.4.6 -pyrsistent==0.17.3 -pytoml==0.1.21 -requests==2.22.0 -retrying==1.3.3 -si-prefix==1.2.2 -six==1.14.0 -urllib3==1.25.8 -webencodings==0.5.1 +-r requirements/base.txt +-r requirements/ethermine.txt +-r requirements/flexpool.txt diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..14649d6 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,29 @@ +appdirs==1.4.4 +attrs==20.3.0 +CacheControl==0.12.6 +certifi==2020.12.5 +chardet==4.0.0 +colorama==0.4.3 +contextlib2==0.6.0 +distlib==0.3.1 +distro==1.4.0 +html5lib==1.0.1 +humanfriendly==9.1 +idna==2.10 +ipaddr==2.2.0 +Jinja2==2.11.2 +jsonschema==3.2.0 +lockfile==0.12.2 +MarkupSafe==1.1.1 +msgpack==0.6.2 +packaging==20.3 +pep517==0.8.2 +progress==1.5 +pyparsing==2.4.6 +pyrsistent==0.17.3 +pytoml==0.1.21 +requests==2.25.1 +retrying==1.3.3 +six==1.15.0 +urllib3==1.26.3 +webencodings==0.5.1 diff --git a/requirements/ethermine.txt b/requirements/ethermine.txt new file mode 100644 index 0000000..73a9562 --- /dev/null +++ b/requirements/ethermine.txt @@ -0,0 +1,9 @@ +cfgv==3.2.0 +ethermine==0.2.0 +filelock==3.0.12 +identify==1.5.10 +nodeenv==1.5.0 +pre-commit==2.9.2 +PyYAML==5.3.1 +toml==0.10.2 +virtualenv==20.2.1 diff --git a/requirements/flexpool.txt b/requirements/flexpool.txt new file mode 100644 index 0000000..461db3a --- /dev/null +++ b/requirements/flexpool.txt @@ -0,0 +1,18 @@ +appdirs==1.4.4 +certifi==2020.12.5 +cfgv==3.2.0 +chardet==4.0.0 +distlib==0.3.1 +filelock==3.0.12 +flexpoolapi==1.2.7.post2 +identify==1.5.10 +idna==2.10 +nodeenv==1.5.0 +pre-commit==2.9.2 +PyYAML==5.3.1 +requests==2.25.1 +si-prefix==1.2.2 +six==1.15.0 +toml==0.10.2 +urllib3==1.26.3 +virtualenv==20.2.1 diff --git a/state.py b/state.py index 8f11d24..e1a9537 100644 --- a/state.py +++ b/state.py @@ -2,29 +2,33 @@ import json import os -def read_state(filename): - with open(filename, 'r') as fd: - return json.load(fd) +class State: + def __init__(self, filename): + self.filename = filename + self.create() + def create(self): + if not os.path.isfile(self.filename): + with open(self.filename, 'w') as fd: + json.dump({}, fd) -def write_state(filename, block_number=None, miner_balance=None, miner_payment=None): - data = {} - if os.path.isfile(filename): - data = read_state(filename) + def read(self): + with open(self.filename, 'r') as fd: + return json.load(fd) - if block_number: - data['block'] = block_number + def write(self, pool_name, block_number=None, miner_balance=None, miner_payment=None): + content = self.read() + if pool_name not in content: + content[pool_name] = {} + if block_number: + content[pool_name]['block'] = block_number + if miner_balance: + content[pool_name]['balance'] = miner_balance + if miner_payment: + content[pool_name]['payment'] = miner_payment + with open(self.filename, 'w') as fd: + json.dump(content, fd, indent=2, separators=(',', ': ')) - if miner_balance: - data['balance'] = miner_balance - - if miner_payment: - data['payment'] = miner_payment - - with open(filename, 'w') as fd: - json.dump(data, fd) - - -def create_state(filename): - if not os.path.isfile(filename): - write_state(filename) + def get(self, key): + content = self.read() + return content.get(key, {}) diff --git a/telegram.py b/telegram.py index f8deebe..2624d74 100644 --- a/telegram.py +++ b/telegram.py @@ -1,5 +1,6 @@ import logging import os +from copy import copy import requests from jinja2 import Environment, FileSystemLoader @@ -8,40 +9,58 @@ logger = logging.getLogger(__name__) absolute_path = os.path.split(os.path.abspath(__file__))[0] -def markdown_escape(text): - text = str(text) - for special_char in ['\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!']: - text = text.replace(special_char, fr'\{special_char}') - return text +class TelegramNotifier: + def __init__(self, chat_id, auth_key): + self._auth_key = auth_key + self._default_payload = {'auth_key': auth_key, 'chat_id': chat_id, 'parse_mode': 'MarkdownV2', + 'disable_web_page_preview': True} + @staticmethod + def _markdown_escape(text): + text = str(text) + for special_char in ['\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!']: + text = text.replace(special_char, fr'\{special_char}') + return text -def create_block_payload(chat_id, message_variables): - return generate_payload(chat_id, message_variables, 'block.md.j2') + def _generate_payload(self, message_variables, template_name): + payload = copy(self._default_payload) + template_path = os.path.join(absolute_path, 'templates') + loader = FileSystemLoader(template_path) + env = Environment(loader=loader) + template = env.get_template(template_name) + template_variables = {} + for key, value in message_variables.items(): + template_variables[key] = self._markdown_escape(value) + text = template.render(**template_variables) + payload['text'] = text + return payload + def notify_block(self, pool, number, hash, reward, time, round_time, luck, reward_fiat=None): + message_variables = {'pool': pool, 'number': number, 'reward': reward, 'time': time, 'round_time': round_time, + 'luck': luck, 'reward_fiat': reward_fiat} + payload = self._generate_payload(message_variables, 'block.md.j2') + self._send_message(payload) -def create_balance_payload(chat_id, message_variables): - return generate_payload(chat_id, message_variables, 'balance.md.j2') + def notify_balance(self, pool, address, url, balance, balance_percentage, balance_fiat=None): + message_variables = {'pool': pool, 'address': address, 'url': url, 'balance': balance, + 'balance_percentage': balance_percentage, 'balance_fiat': balance_fiat} + payload = self._generate_payload(message_variables, 'balance.md.j2') + self._send_message(payload) + def notify_payment(self, pool, address, txid, amount, time, duration, amount_fiat=None): + message_variables = {'pool': pool, 'address': address, 'txid': txid, 'amount': amount, + 'amount_fiat': amount_fiat, 'time': time, 'duration': duration} + payload = self._generate_payload(message_variables, 'payment.md.j2') + self._send_message(payload) -def create_payment_payload(chat_id, message_variables): - return generate_payload(chat_id, message_variables, 'payment.md.j2') + def _send_message(self, payload): + logger.debug(self._sanitize(payload)) + r = requests.post(f'https://api.telegram.org/bot{self._auth_key}/sendMessage', json=payload) + r.raise_for_status() - -def generate_payload(chat_id, message_variables, template_name): - payload = {'chat_id': chat_id, 'parse_mode': 'MarkdownV2', 'disable_web_page_preview': True} - template_path = os.path.join(absolute_path, 'templates') - loader = FileSystemLoader(template_path) - env = Environment(loader=loader) - template = env.get_template(template_name) - template_variables = {} - for key, value in message_variables.items(): - template_variables[key] = markdown_escape(value) - text = template.render(**template_variables) - payload['text'] = text - return payload - - -def send_message(auth_key, payload): - logger.debug(payload) - r = requests.post(f'https://api.telegram.org/bot{auth_key}/sendMessage', json=payload) - r.raise_for_status() + @staticmethod + def _sanitize(payload): + payload = copy(payload) + if 'auth_key' in payload: + payload['auth_key'] = '***' + return payload diff --git a/templates/balance.md.j2 b/templates/balance.md.j2 index 22b4f8e..96d0f78 100644 --- a/templates/balance.md.j2 +++ b/templates/balance.md.j2 @@ -1,5 +1,5 @@ -*💰 New balance* +*💰 New {{pool}} balance* -*Address*: [{{address}}](https://flexpool.io/{{address}}) +*Address*: [{{address}}]({{url}}) *Unpaid balance*: {{balance}} {% if balance_fiat != 'None' %}\({{balance_fiat}}\){% endif %} *Unpaid percentage*: {{balance_percentage}} diff --git a/templates/block.md.j2 b/templates/block.md.j2 index 1be4ec6..9a4cf8b 100644 --- a/templates/block.md.j2 +++ b/templates/block.md.j2 @@ -1,4 +1,4 @@ -*⛏️ New block* +*⛏️ New {{pool}} block* *Number*: [{{number}}](https://etherscan.io/block/{{hash}}) *Reward*: {{reward}} {% if reward_fiat != 'None' %}\({{reward_fiat}}\){% endif %} diff --git a/templates/payment.md.j2 b/templates/payment.md.j2 index 227ca37..13311c8 100644 --- a/templates/payment.md.j2 +++ b/templates/payment.md.j2 @@ -1,4 +1,4 @@ -*💵 New payment* +*💵 New {{pool}} payment* *Amount*: {{amount}} {% if amount_fiat != 'None' %}\({{amount_fiat}}\){% endif %} *ID*: [{{txid}}](https://etherscan.io/tx/{{txid}}) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..03bda2e --- /dev/null +++ b/utils.py @@ -0,0 +1,15 @@ +def convert_weis(weis, precision=5): + return round(weis / 10**18, precision) + + +def format_weis(weis, precision=5): + amount = convert_weis(weis=weis, precision=precision) + if amount == int(amount): + amount = int(amount) + return f'{amount} ETH' + + +def convert_fiat(amount, exchange_rate, currency): + converted = round(convert_weis(amount)*exchange_rate, 2) + converted = f'{converted} {currency}' + return converted